view_component_atomix 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +744 -0
  5. data/lib/view_component/atomix/accessors.rb +129 -0
  6. data/lib/view_component/atomix/attribute_helpers.rb +15 -0
  7. data/lib/view_component/atomix/attrs.rb +17 -0
  8. data/lib/view_component/atomix/base.rb +226 -0
  9. data/lib/view_component/atomix/builder.rb +77 -0
  10. data/lib/view_component/atomix/compiler.rb +147 -0
  11. data/lib/view_component/atomix/condition_resolver.rb +79 -0
  12. data/lib/view_component/atomix/configuration.rb +103 -0
  13. data/lib/view_component/atomix/declaration_queue.rb +50 -0
  14. data/lib/view_component/atomix/dynamic_properties/literal_key.rb +68 -0
  15. data/lib/view_component/atomix/dynamic_properties/source_cache.rb +31 -0
  16. data/lib/view_component/atomix/dynamic_properties/source_parser.rb +61 -0
  17. data/lib/view_component/atomix/dynamic_properties.rb +75 -0
  18. data/lib/view_component/atomix/dynamic_style.rb +109 -0
  19. data/lib/view_component/atomix/enumerated_style.rb +39 -0
  20. data/lib/view_component/atomix/hashing.rb +47 -0
  21. data/lib/view_component/atomix/helper.rb +31 -0
  22. data/lib/view_component/atomix/immutable.rb +20 -0
  23. data/lib/view_component/atomix/known_properties.rb +102 -0
  24. data/lib/view_component/atomix/owner_state.rb +48 -0
  25. data/lib/view_component/atomix/property.rb +183 -0
  26. data/lib/view_component/atomix/railtie.rb +46 -0
  27. data/lib/view_component/atomix/registry.rb +208 -0
  28. data/lib/view_component/atomix/resolution_session.rb +81 -0
  29. data/lib/view_component/atomix/rule.rb +42 -0
  30. data/lib/view_component/atomix/sheet.rb +98 -0
  31. data/lib/view_component/atomix/style.rb +80 -0
  32. data/lib/view_component/atomix/tasks/build.rake +17 -0
  33. data/lib/view_component/atomix/theme_compiler.rb +19 -0
  34. data/lib/view_component/atomix/theme_resolution.rb +66 -0
  35. data/lib/view_component/atomix/trait_classifier.rb +111 -0
  36. data/lib/view_component/atomix/vars.rb +97 -0
  37. data/lib/view_component/atomix/version.rb +7 -0
  38. data/lib/view_component/atomix/weak_collection.rb +62 -0
  39. data/lib/view_component/atomix.rb +301 -0
  40. data/lib/view_component_atomix.rb +3 -0
  41. metadata +162 -0
data/README.md ADDED
@@ -0,0 +1,744 @@
1
+ # ViewComponent::Atomix
2
+
3
+ StyleX-like atomic CSS for [ViewComponent](https://viewcomponent.org) and plain Rails views. You declare styles in Ruby, next to the component that uses them. Each property/value pair compiles to one deterministic atomic class, styles merge per property with last-wins semantics, and the CSS is collected in a registry and emitted with no JavaScript build step.
4
+
5
+ ```ruby
6
+ class ButtonComponent < ViewComponent::Base
7
+ include ViewComponent::Atomix
8
+
9
+ styles do
10
+ trait :button, padding: "8px 16px",
11
+ border_radius: 4,
12
+ background: { default: "#fff", hover: "#eee" }
13
+
14
+ trait :size, default: :md,
15
+ sm: { font_size: 14 },
16
+ md: { font_size: 16 },
17
+ lg: { font_size: 20 }
18
+ end
19
+
20
+ def initialize(size: nil)
21
+ @size = size
22
+ end
23
+
24
+ erb_template <<~ERB
25
+ <button <%= styles.props(styles.button, styles.size(@size)) %>><%= content %></button>
26
+ ERB
27
+ end
28
+ ```
29
+
30
+ `render ButtonComponent.new(size: :lg)` produces:
31
+
32
+ ```html
33
+ <button class="vc4dud5thf vc968v4d1e vcph2rfytx vc8ii37bsa vcigwbeo1f">
34
+ ```
35
+
36
+ and the stylesheet contains one rule per declaration:
37
+
38
+ ```css
39
+ .vc4dud5thf{padding:8px 16px}
40
+ .vcph2rfytx{background:#fff}
41
+ .vc8ii37bsa:hover{background:#eee}
42
+ .vc968v4d1e{border-radius:4px}
43
+ .vcooz8b8nn{font-size:14px}
44
+ .vc34mq84yv{font-size:16px}
45
+ .vcigwbeo1f{font-size:20px}
46
+ ```
47
+
48
+ A few ideas run through everything below:
49
+
50
+ - A **trait** is a named group of declarations. `trait :button, ...` declares one and `styles.button` reads it back as a `Style`.
51
+ - A **Style** is a set of class names, one per property. Passing several to `styles.props` merges them property by property, so the last style to set a property wins.
52
+ - **Conditions** such as `hover` or a breakpoint nest inside a property's value, which is what keeps per-property merging correct.
53
+ - Every rule lands in a global **registry**, and the layout tag emits it as CSS.
54
+
55
+ ## Installation
56
+
57
+ Requires Ruby 3.1+, Rails 7.0+, and ViewComponent 3 or 4.
58
+
59
+ ```ruby
60
+ gem "view_component_atomix"
61
+ ```
62
+
63
+ The API is pre-1.0 and may change between minor versions. Changes are listed in [CHANGELOG.md](CHANGELOG.md).
64
+
65
+ Add the stylesheet tag to your layout:
66
+
67
+ ```erb
68
+ <head>
69
+ <%= view_component_atomix_tag %>
70
+ </head>
71
+ ```
72
+
73
+ ### Development and production
74
+
75
+ Outside production the tag inlines a `<style>` block, so there is nothing to build while developing. Whenever the app boots or reloads, the gem loads `app/components` and `app/styles` first so the block contains every style.
76
+
77
+ In production the tag links `view_component_atomix.css`, which `rake view_component:atomix:build` writes to `app/assets/builds/`. The build task is inserted as the first prerequisite of `assets:precompile`, so it runs before any `css:build` or `javascript:build` step. Add the output file to `.gitignore`.
78
+
79
+ Sprockets users need `//= link_tree ../builds` in `app/assets/config/manifest.js`. Propshaft picks the file up automatically.
80
+
81
+ Styles must be declared while the app loads: in `styles`, `style_vars` and `theme` declarations, or in constants under `app/`. A `create`, `define_vars` or `create_theme` call that first runs during a request adds a rule the built production file does not contain, and one more rule to the registry for every distinct value. For values only known at request time, use a [dynamic trait](#dynamic-traits).
82
+
83
+ ### Content Security Policy
84
+
85
+ Pass `nonce: true` to add the request's CSP nonce to the inline `<style>` tag or the stylesheet link:
86
+
87
+ ```erb
88
+ <%= view_component_atomix_tag nonce: true %>
89
+ ```
90
+
91
+ Static traits, variables and themes only add classes. Dynamic traits set CSS variables through a `style` attribute, which a policy has to allow with `style-src-attr 'unsafe-inline'`.
92
+
93
+ Like tailwindcss-rails, this gem targets the Rails asset pipeline and does not integrate with esbuild, Vite, or other JavaScript bundlers. Apps using jsbundling-rails or cssbundling-rails still have the pipeline serving `app/assets/builds`, so they work unchanged.
94
+
95
+ ## Declaring styles
96
+
97
+ `include ViewComponent::Atomix` in a component and open a `styles` block. Each `trait` call declares a named style, and `styles` with no block returns the compiled sheet, both at the class level and inside templates.
98
+
99
+ ```ruby
100
+ # app/components/card_component.rb
101
+ class CardComponent < ViewComponent::Base
102
+ include ViewComponent::Atomix
103
+
104
+ styles do
105
+ trait :card, display: "flex", gap: 8, padding: 16
106
+ trait :compact, gap: 4, padding: 8
107
+ end
108
+
109
+ def initialize(compact: false)
110
+ @compact = compact
111
+ end
112
+
113
+ erb_template <<~ERB
114
+ <div <%= styles.props(styles.card, @compact && styles.compact) %>><%= content %></div>
115
+ ERB
116
+ end
117
+
118
+ CardComponent.styles.card # => Style
119
+ CardComponent.styles[:compact] # => Style
120
+ CardComponent.styles.names # => [:card, :compact]
121
+ ```
122
+
123
+ Calling `styles` again merges into the same sheet, and subclasses extend their parent's sheet without changing it. A trait declared later with the same name replaces the earlier one.
124
+
125
+ The block is evaluated when the sheet is first read, not when the class loads, so a trait can reference a constant that is not loaded yet. Class-level methods, including `styles` itself, work inside the block.
126
+
127
+ ```ruby
128
+ class BadgeComponent < ViewComponent::Base
129
+ include ViewComponent::Atomix
130
+
131
+ styles do
132
+ trait :root, color: Brand::VARS.primary
133
+ end
134
+ end
135
+ ```
136
+
137
+ ### Values
138
+
139
+ - **Numbers** become `px` except for unitless properties such as `line_height`, `opacity`, `z_index`, `flex` and `font_weight`. Time properties such as `transition_duration` use milliseconds, including `0ms`. Custom properties stay unitless. Zero lengths stay `0`.
140
+ - **Symbols** become kebab-case values, so `display: :inline_block` works.
141
+ - **Arrays** in static traits emit fallback declarations in order: `position: %w[sticky fixed]`.
142
+ - **nil** unsets the property, removing whatever an earlier style set.
143
+ - **Keys** are snake_case symbols (`border_radius`), camelCase strings, or raw CSS names. Custom properties like `"--gap"` pass through.
144
+
145
+ ### Conditions
146
+
147
+ A condition is a pseudo-class, media query, or breakpoint. Conditions live inside a property's value, never at the top level of a trait. This is what makes per-property merging correct: a later style that sets `background` replaces the base value *and* its hover value.
148
+
149
+ ```ruby
150
+ styles do
151
+ trait :root,
152
+ color: {
153
+ default: "#111",
154
+ hover: "#000",
155
+ md: { default: "#222", hover: "#333" },
156
+ ":nth-child(2)" => "red",
157
+ "@supports (display: grid)" => "blue"
158
+ },
159
+ before: { content: '""', display: "block" }
160
+ end
161
+ ```
162
+
163
+ Symbol keys resolve to pseudo-classes (`hover`, `focus`, `focus_visible`, `focus_within`, `active`, `disabled`, `checked`, `first_child`, `last_child`, `placeholder_shown`, ...), a few media shortcuts (`dark`, `light`, `reduced_motion`, `print`), or breakpoints you configure. String keys must start with `:` or `@` and are passed through as written, so any selector or at-rule is available without configuration. Keep raw queries in constants if you want names:
164
+
165
+ ```ruby
166
+ module Breakpoints
167
+ TABLET = "@media (max-width: 768px)"
168
+ end
169
+
170
+ styles do
171
+ trait :root, padding: { default: 16, Breakpoints::TABLET => 12 }
172
+ end
173
+ ```
174
+
175
+ Pseudo-elements (`before`, `after`, `placeholder`, `selection`, `marker`) are the one exception to nesting: they are top-level keys containing their own properties, as `before` is above.
176
+
177
+ ### Breakpoints
178
+
179
+ Breakpoints are symbols you name yourself, and there are none by default. Configure them app-wide, or declare them inside a `styles` block with `breakpoints`, which scopes them to that component and its subclasses and overrides the app-wide value for the same name:
180
+
181
+ ```ruby
182
+ # config/application.rb or an initializer
183
+ config.view_component.atomix.breakpoints = {
184
+ sm: "(max-width: 640px)",
185
+ md: "(max-width: 768px)"
186
+ }
187
+
188
+ # app/components/hero_component.rb
189
+ styles do
190
+ breakpoints wide: "(min-width: 1400px)", md: "(max-width: 900px)"
191
+
192
+ trait :root, padding: { default: 16, md: 12, wide: 32 }
193
+ end
194
+ ```
195
+
196
+ Breakpoints declared in a block apply to that block and every later `styles` block on the class, so put them in the first one.
197
+
198
+ Breakpoint names must be symbols and queries must be strings. Global and component-local declarations both reject names that collide with pseudo-class or media shortcuts, such as `hover` or `dark`.
199
+
200
+ ## Applying styles
201
+
202
+ `styles.props` takes any number of styles and returns the `class` and `style` attributes for an element. It skips `nil` and `false`, so conditionals read naturally:
203
+
204
+ ```erb
205
+ <div <%= styles.props(styles.root, active? && styles.highlighted, @extra) %>>
206
+ ```
207
+
208
+ The return value is a Hash of `class` and `style` that also renders as an escaped attribute string, so it works both interpolated into ERB and splatted into a tag helper: `tag.div(**styles.props(styles.root))`. Every sheet has `props`, so `css.props` and `pill_styles.props` later in this guide are the same method.
209
+
210
+ Merging is per CSS property, and the last argument to set a property wins. Shorthand and longhand properties are distinct keys, and the emitted CSS is sorted so longhands come after shorthands and `:active` after `:hover`. That means `padding_top` always beats `padding` regardless of definition order.
211
+
212
+ Plain views have no `styles` reader, so they use the `sx` helper instead. It takes the same arguments as `props` and is available in every view and template, along with `sx_attrs`, which returns the Hash form for `tag.div(**sx_attrs(...))`. Outside views, `ViewComponent::Atomix.props` and `ViewComponent::Atomix.sx` do the same.
213
+
214
+ ## Kinds of traits
215
+
216
+ A trait is one of four things, told apart by the shape of what follows the name:
217
+
218
+ ```ruby
219
+ styles do
220
+ trait :button, padding: 8, border_radius: 4 # plain: a hash of properties
221
+ trait :size, default: :md, sm: { font_size: 14 }, # enumerated: a hash of member hashes
222
+ md: { font_size: 16 }, lg: { font_size: 20 }
223
+ trait :cta, :button, size: :lg, font_weight: 700 # combination: references to earlier traits
224
+ trait :sized, ->(w) { { width: w } } # dynamic: a lambda
225
+ end
226
+ ```
227
+
228
+ ### Plain traits
229
+
230
+ A hash of properties compiles to a `Style`, and `styles.button` reads it. `trait :small, { padding: 6 }` with an explicit hash is the same thing.
231
+
232
+ ### Enumerated traits
233
+
234
+ A hash whose values are all hashes of properties declares named members. `styles.size(:lg)` returns the member, `styles.size(nil)` and `styles.size` return the default or an empty style, and an unknown member raises `KeyError`. `default:` is optional.
235
+
236
+ A hash is read as members only when no key is a CSS property, a pseudo-element, a pseudo-class symbol such as `hover`, or a configured breakpoint. Nothing is configured by default, so `sm`, `md` and `lg` are free to use as members until you claim them as breakpoints. A name that is a breakpoint cannot also name a member, and mixing members with properties raises.
237
+
238
+ ### Combination traits
239
+
240
+ A combination references traits declared earlier: in this block, an earlier `styles` block, a parent class, or a shared module merged into the same sheet. Bare symbols after the name include plain traits, and a keyword whose name is an enumerated trait selects a member. `name: true` also works, and on an enumerated trait it selects the default. Every other keyword is a property. The result is `props` of the references in order followed by the properties, so the properties win. Dynamic traits cannot be referenced.
241
+
242
+ ### Dynamic traits
243
+
244
+ A lambda value becomes a dynamic style. The class it compiles to reads a CSS variable, and calling it sets that variable inline.
245
+
246
+ ```ruby
247
+ styles do
248
+ trait :box, ->(w, h) { { width: w, height: h } }
249
+ end
250
+
251
+ styles.box(120, 40) # class="vc…" style="--vc…:120px;--vc…:40px"
252
+ styles.box(nil, nil) # unsets width and height
253
+ ```
254
+
255
+ The lambda's source is parsed without executing it. Its body must be one literal Hash with static property keys; values can contain arbitrary runtime expressions. This lets asset precompilation discover every variable-backed rule while requests supply only the values. Keep optional keys in the hash and return `nil` when they do not apply:
256
+
257
+ ```ruby
258
+ trait :box, ->(expanded) {
259
+ {
260
+ height: expanded ? "auto" : 0,
261
+ overflow: expanded ? "visible" : nil
262
+ }
263
+ }
264
+ ```
265
+
266
+ Dynamic functions run for explicit `nil` and `false` arguments, so defaults and conditional expressions still work. A `nil` or `false` result for a property removes that property from earlier styles. Calling a function with required arguments and no arguments returns an empty style.
267
+
268
+ Dynamic values must be single CSS values. Nested condition hashes are not supported in dynamic traits; put conditions in a static trait referencing a CSS variable. Arrays are rejected: CSS custom properties cannot select browser-supported fallback values the way repeated static declarations do. Declare fallback arrays in a static trait instead.
269
+
270
+ Dynamic values become CSS custom-property values in the element's `style` attribute. A value that could end its declaration or swallow the ones after it raises `ArgumentError`: a `;` outside a string or function, or unbalanced quotes, brackets or comments. Static, variable and theme values are checked the same way. This stops a value from adding declarations of its own, but it does not make an arbitrary value safe for the property it lands in, so still validate untrusted input against the values or formats your property accepts.
271
+
272
+ A dynamic trait can also set a variable for a subtree at request time, which is how to apply a value such as a per-tenant colour that no theme could declare in advance:
273
+
274
+ ```ruby
275
+ trait :branded,
276
+ dynamic: [Brand::VARS.var_name(:primary)],
277
+ using: ->(color) { { Brand::VARS.var_name(:primary) => color } }
278
+ ```
279
+
280
+ Helper-built hashes and conditional hash shapes hide their keys from source compilation. For those cases, declare the complete property set explicitly:
281
+
282
+ ```ruby
283
+ trait :box,
284
+ dynamic: %i[height overflow],
285
+ using: ->(expanded) { build_box_styles(expanded) }
286
+ ```
287
+
288
+ Runtime results may omit declared properties, but returning a property that was not compiled raises an error.
289
+
290
+ ### Naming traits
291
+
292
+ Because reference keys in a combination are told apart from property keys by name, a trait cannot be named after a CSS property. That rules out some tempting names: `width`, `container`, `content`, `overlay`, `marker`, `fill`, `page`, `top`. Use `sized`, `wrapper`, `body`, `backdrop`, or similar.
293
+
294
+ A trait also cannot share its name with a public method of the sheet, since `styles.names` would call the method instead of returning the trait. That rules out `names`, `vars`, `props`, `merge`, `fetch`, and `Object` methods such as `hash`, `class`, `method` and `then`. Declaring one raises `ArgumentError`.
295
+
296
+ ## Sharing styles
297
+
298
+ Styles can live in three places, and they combine freely:
299
+
300
+ - **In a component** with the `styles` block, as above. Subclasses inherit the sheet.
301
+ - **In a shared module** that includes `ViewComponent::Atomix`, then included into components.
302
+ - **In plain Ruby** as constants built with `ViewComponent::Atomix.create`, referenced from anywhere.
303
+
304
+ ### Shared modules
305
+
306
+ A module can `include ViewComponent::Atomix` and declare traits in a `styles` block. Including that module in a component exposes its sheet under an accessor derived from the module name, at the class level and inside templates. The component's own `styles` stays separate.
307
+
308
+ The accessor is the underscored module name, namespaces included, with a trailing `Styles`, `Style`, `Theme`, `Vars` or `Variables` removed, plus `_styles`. `PillStyles` becomes `pill_styles`, `Admin::ProfileCardStyles` becomes `admin_profile_card_styles`, and `DarkModeStyle` becomes `dark_mode_styles`.
309
+
310
+ ```ruby
311
+ # app/styles/pill_styles.rb
312
+ module PillStyles
313
+ include ViewComponent::Atomix
314
+
315
+ styles do
316
+ trait :pill, display: :inline_block, border_radius: 999, padding: "2px 8px"
317
+ end
318
+ end
319
+
320
+ # app/components/pill_component.rb
321
+ class PillComponent < ViewComponent::Base
322
+ include PillStyles
323
+
324
+ styles do
325
+ trait :tone, color: "#0055ff"
326
+ end
327
+
328
+ erb_template <<~ERB
329
+ <span <%= styles.props(pill_styles.pill, styles.tone) %>><%= content %></span>
330
+ ERB
331
+ end
332
+
333
+ PillComponent.styles.names # => [:tone]
334
+ PillComponent.pill_styles.names # => [:pill]
335
+ ```
336
+
337
+ Use a plain module. One that extends `ActiveSupport::Concern` defers the include, so `styles` is not available inside the module body. An anonymous module has no name to derive an accessor from, so its traits merge into the component's `styles` instead.
338
+
339
+ Style files live in either `app/styles` or `app/components/styles`. Both are autoload roots, so `app/components/styles/pill_styles.rb` defines `PillStyles`, not `Styles::PillStyles`.
340
+
341
+ The gem registers `app/components/styles` as a root itself. If your app already has that directory and expects `Styles::` constants in it, set `config.view_component.atomix.autoload_roots = []` in `config/application.rb`. Files in that directory then follow the Rails naming rules, so `app/components/styles/pill_styles.rb` has to define `Styles::PillStyles` or eager loading fails.
342
+
343
+ ### Companion modules
344
+
345
+ A module named after a component with `Styles` appended is picked up without an `include`. Its traits, variables and theme merge straight into the component's own `styles` and `theme`, as if they had been declared in the class body, so the component's own declarations win on conflicts and combination traits can reference the module's traits. `ButtonComponent` looks for `ButtonComponentStyles`, and `Admin::ButtonComponent` looks for `Admin::ButtonComponentStyles`. The component still needs `include ViewComponent::Atomix`.
346
+
347
+ ```ruby
348
+ # app/components/toast_component_styles.rb
349
+ module ToastComponentStyles
350
+ include ViewComponent::Atomix
351
+
352
+ styles do
353
+ trait :root, display: :flex, gap: 6, padding: 12
354
+ end
355
+ end
356
+
357
+ # app/components/toast_component.rb
358
+ class ToastComponent < ViewComponent::Base
359
+ include ViewComponent::Atomix
360
+
361
+ styles do
362
+ trait :cta, :root, font_weight: 700
363
+ end
364
+
365
+ erb_template <<~ERB
366
+ <div <%= styles.props(styles.root) %>><%= content %></div>
367
+ ERB
368
+ end
369
+
370
+ ToastComponent.styles.names # => [:root, :cta]
371
+ ```
372
+
373
+ The lookup is per class, so a subclass inherits its parent's companion and can add its own. A constant with the matching name that is not a style module is ignored.
374
+
375
+ ### Plain Ruby constants
376
+
377
+ Everything a component can do, plain Ruby can do with `ViewComponent::Atomix.create`. It takes a hash of traits, and each value follows the same shape rules as `trait`. A block with `trait` calls works too. The result is an ordinary sheet, so assign it to a constant and reference it from any view or component:
378
+
379
+ ```ruby
380
+ # app/styles/card_styles.rb
381
+ module CardStyles
382
+ CARD = ViewComponent::Atomix.create(
383
+ root: { padding: 24, border_radius: 8, background: "#fff" },
384
+ title: { font_size: 20, font_weight: 600 }
385
+ )
386
+ end
387
+ ```
388
+
389
+ ```erb
390
+ <%# app/views/cards/show.html.erb %>
391
+ <div <%= sx(CardStyles::CARD.root) %>>
392
+ <h2 <%= sx(CardStyles::CARD.title) %>><%= @card.title %></h2>
393
+ </div>
394
+ ```
395
+
396
+ For dynamic traits, retain the sheet constant until the asset build runs so the compiler can discover its properties. A literal-hash lambda needs no explicit `dynamic:` list:
397
+
398
+ ```ruby
399
+ SHEET = ViewComponent::Atomix.create(box: ->(width) { { width: width } })
400
+ # At render time:
401
+ ViewComponent::Atomix.props(SHEET.box(120))
402
+ ```
403
+
404
+ Keeping only the result of `create(...).box(120)` allows the declaration to be garbage-collected before production CSS compilation.
405
+
406
+ The same constants work inside a component template, with or without `include ViewComponent::Atomix`:
407
+
408
+ ```ruby
409
+ class CardComponent < ViewComponent::Base
410
+ erb_template <<~ERB
411
+ <div <%= sx(CardStyles::CARD.root) %>><%= content %></div>
412
+ ERB
413
+ end
414
+ ```
415
+
416
+ A module can hold both kinds: constants built with `create` alongside traits declared with `styles`. A module that only holds constants is not a style module, so including it in a component adds nothing beyond ordinary Ruby.
417
+
418
+ ### Renaming `styles`
419
+
420
+ If `styles` clashes with something in your component, pick another name. `styles_method` renames both the class macro and the instance reader, and the old name is removed. Subclasses inherit the name.
421
+
422
+ ```ruby
423
+ class PanelComponent < ViewComponent::Base
424
+ include ViewComponent::Atomix
425
+ styles_method :css
426
+
427
+ css do
428
+ trait :root, padding: 24
429
+ end
430
+
431
+ erb_template <<~ERB
432
+ <section <%= css.props(css.root) %>><%= content %></section>
433
+ ERB
434
+ end
435
+
436
+ PanelComponent.css.root # => Style
437
+ ```
438
+
439
+ Set `config.view_component.atomix.styles_method = :css` in an initializer to change the default for every component that includes the module afterwards.
440
+
441
+ A shared module that declares `styles_method :css` is exposed as `css` rather than under a derived accessor. If the component declares the same name, the two sheets merge into one and the component's traits win on conflicts. Only an explicit `styles_method` call inside the module counts, not the configured default.
442
+
443
+ A subclass can also adopt an inherited module accessor with `styles_method`.
444
+ Its sheet retains the traits and variables exposed by that reader, alongside
445
+ the parent's primary sheet. If several modules share a separate accessor, the
446
+ last included module supplies that reader. The subclass's companion, directly
447
+ included matching modules, and own declarations take precedence, in that order.
448
+ Modules already merged into a parent's primary sheet are inherited with the
449
+ parent's overrides intact.
450
+
451
+ ```ruby
452
+ # app/styles/tag_styles.rb
453
+ module TagStyles
454
+ include ViewComponent::Atomix
455
+ styles_method :css
456
+
457
+ css do
458
+ trait :tag, padding: 4
459
+ end
460
+ end
461
+
462
+ # app/components/tag_component.rb
463
+ class TagComponent < ViewComponent::Base
464
+ include TagStyles
465
+ styles_method :css
466
+
467
+ css do
468
+ trait :label, font_weight: 600
469
+ end
470
+
471
+ erb_template <<~ERB
472
+ <span <%= css.props(css.tag, css.label) %>><%= content %></span>
473
+ ERB
474
+ end
475
+
476
+ TagComponent.css.names # => [:tag, :label]
477
+ ```
478
+
479
+ ## Theming
480
+
481
+ Theming is two separate ideas:
482
+
483
+ - **Variables** are CSS custom properties. You declare them once with a default value, and styles reference them by name instead of hard-coding the value.
484
+ - **Themes** are overrides. A theme is a `Style` whose single class sets different values for some variables. Put it on a wrapper with `styles.props` and every descendant that reads those variables picks up the new values through the cascade.
485
+
486
+ Both come with `include ViewComponent::Atomix`. Each is also available on its own: `include ViewComponent::Atomix::Variables` gives you variables and the `styles` reader without traits, and `include ViewComponent::Atomix::Theme` gives you `theme` and nothing else.
487
+
488
+ ### Variables
489
+
490
+ Declare variables in a component with `style_vars`. They are read back through `styles`, alongside traits, and return a `var()` reference:
491
+
492
+ ```ruby
493
+ class PanelComponent < ViewComponent::Base
494
+ include ViewComponent::Atomix
495
+
496
+ style_vars gap: "12px", accent: "#ff6600"
497
+
498
+ styles do
499
+ trait :root, display: "grid", gap: styles.gap, border_color: styles.accent
500
+ end
501
+ end
502
+
503
+ PanelComponent.styles.gap # => "var(--vcnbqr2vub)"
504
+ PanelComponent.styles.var_names # => [:gap, :accent]
505
+ ```
506
+
507
+ This emits `:root{--vcnbqr2vub:12px}` and `:root{--vclpsr1n9z:#ff6600}` in the stylesheet. Repeated `style_vars` calls merge, the block form defers evaluation like `styles do`, and subclasses inherit and can redeclare individual keys.
508
+
509
+ Redeclaring a key from the same source replaces its entire value, including its
510
+ conditions. For example, replacing `{ default: "red", dark: "black" }` with
511
+ `"blue"` removes the old dark-mode branch. A `nil` or empty-hash value removes
512
+ that source's assignments while retaining the variable reference. Other keys
513
+ and assignments owned by other source files remain intact. This also applies
514
+ to repeated `define_vars` calls with the same name. Failed replacements leave
515
+ the previous assignments intact.
516
+
517
+ Rules for values:
518
+
519
+ - Values follow the same rules as style values. Numeric variables stay unitless, matching StyleX. Use explicit units for lengths (`"12px"`) and times (`"200ms"`).
520
+ - A hash value declares conditions the same way a property does inside a trait: `default` plus any pseudo-class symbol, breakpoint, or raw `@media` string. Only at-rules make sense on `:root`.
521
+ - Variable names are hashed from the declaring class or file and the key. Pass `name:` to hash from a stable string instead.
522
+ - A key cannot share its name with a public method of `Vars` or the sheet, such as `id`, `names`, `theme` or `hash`. Declaring one raises `ArgumentError`.
523
+
524
+ ### App-wide variables
525
+
526
+ Variables shared across the app live in a plain Ruby file and are built with `define_vars`:
527
+
528
+ ```ruby
529
+ # app/styles/brand.rb
530
+ module Brand
531
+ VARS = ViewComponent::Atomix.define_vars(
532
+ primary: "#0055ff",
533
+ surface: { default: "#fff", dark: "#111" },
534
+ weight: "600"
535
+ )
536
+ end
537
+ ```
538
+
539
+ ```ruby
540
+ # config/application.rb or an initializer
541
+ config.view_component.atomix.vars = -> { Brand::VARS }
542
+ ```
543
+
544
+ Once configured, every component's `styles` falls back to those variables, so `styles.primary` works in any component without declaring anything. A component's own `style_vars` shadow the configured ones. Outside components, read them from the constant or `ViewComponent::Atomix.vars`.
545
+
546
+ The setting accepts a `Vars` object, a callable, or a constant name as a string. Prefer the callable or string form in an initializer, since `app/styles` is not loaded yet when initializers run.
547
+
548
+ ### Themes
549
+
550
+ Declare a theme with `theme`. Keys resolve against the variables visible through `styles`: the component's own `style_vars` first, then the configured app-wide vars. Read it back with `theme` and apply it with `styles.props`:
551
+
552
+ ```ruby
553
+ class PanelComponent < ViewComponent::Base
554
+ include ViewComponent::Atomix
555
+
556
+ style_vars accent: "#ff6600"
557
+
558
+ theme accent: "#00cc66", primary: "#000"
559
+
560
+ styles do
561
+ trait :root, border_color: styles.accent, color: styles.primary
562
+ end
563
+
564
+ erb_template <<~ERB
565
+ <section <%= styles.props(theme, styles.root) %>><%= content %></section>
566
+ ERB
567
+ end
568
+ ```
569
+
570
+ The emitted CSS is one class per set of variables the theme touches, for example `.vccn09cvj7{--vclpsr1n9z:#00cc66}`. Everything inside the section that reads `accent` or `primary` now sees the new values. Nothing in the child components changes.
571
+
572
+ - Pass a `Vars` object first to target a specific set: `theme Brand::VARS, primary: "#000"`.
573
+ - Override values can be conditional hashes, so a theme can change a variable only under `dark` or a breakpoint.
574
+ - Unknown keys raise `KeyError` when the theme is first read.
575
+ - `theme` with no declarations returns an empty style, so `styles.props(theme)` is always safe.
576
+ - Subclasses inherit the parent's theme. Declaring a theme for the same `Vars` set replaces that entire inherited theme, including overrides for keys omitted by the child. Repeat any parent overrides you want to retain; themes targeting other variable sets remain inherited.
577
+ - Two themes for the same `Vars` set are mutually exclusive when merged, and the last one wins. Themes nest: an inner wrapper's theme overrides an outer one for its subtree.
578
+
579
+ For plain Ruby, `ViewComponent::Atomix.create_theme(vars, **overrides)` builds the same `Style` from an explicit `Vars` object, and `vars.theme(**overrides)` is shorthand for it.
580
+
581
+ ### Theme modules
582
+
583
+ Put a theme in a module to share it. Including the module in a component exposes the theme as `<name>_theme`, where the name is derived the same way as for shared style modules, so `Admin::DarkTheme` becomes `admin_dark_theme`. The component's own `theme` stays separate. A module that only includes `ViewComponent::Atomix::Theme` has no variables of its own, so its keys resolve against the app-wide vars.
584
+
585
+ ```ruby
586
+ # app/styles/dark_mode_theme.rb
587
+ module DarkModeTheme
588
+ include ViewComponent::Atomix::Theme
589
+
590
+ theme surface: "#000", primary: "#99bbff"
591
+ end
592
+
593
+ class PanelComponent < ViewComponent::Base
594
+ include ViewComponent::Atomix
595
+ include DarkModeTheme
596
+
597
+ theme accent: "#00cc66"
598
+
599
+ def initialize(dark: false)
600
+ @dark = dark
601
+ end
602
+
603
+ erb_template <<~ERB
604
+ <section <%= styles.props(@dark ? dark_mode_theme : theme, styles.root) %>><%= content %></section>
605
+ ERB
606
+ end
607
+ ```
608
+
609
+ A module named `DarkModeStyle` that declares both traits and a theme is exposed as `dark_mode_styles` and `dark_mode_theme`. A module that includes `ViewComponent::Atomix::Variables` follows the styles rule: `BrandVars` is exposed as `brand_styles`. An anonymous module merges into the component's `theme` or `styles` instead.
610
+
611
+ ### Dark mode and responsive variables
612
+
613
+ Because conditions live on the variable declaration, most dark mode work happens in `define_vars` or `style_vars` and components stay unaware of it:
614
+
615
+ ```ruby
616
+ VARS = ViewComponent::Atomix.define_vars(
617
+ surface: { default: "#fff", dark: "#111" },
618
+ text: { default: "#111", dark: "#eee" },
619
+ gutter: { default: 16, md: 12 }
620
+ )
621
+ ```
622
+
623
+ For a user-selectable theme rather than a media query, declare one theme module per option and apply the chosen one on `<body>` or a layout wrapper.
624
+
625
+ ## Configuration
626
+
627
+ All options live under `config.view_component.atomix`.
628
+
629
+ | Option | Default | Purpose |
630
+ | --- | --- | --- |
631
+ | `inline` | `true` outside production | Inline a `<style>` tag instead of linking the built file |
632
+ | `output_path` | `app/assets/builds/view_component_atomix.css` | Where the build task writes |
633
+ | `breakpoints` | `{}` | Symbol keys usable as conditions app-wide; `breakpoints` inside a `styles` block adds or overrides per component |
634
+ | `pseudo_map` | see `Configuration::DEFAULT_PSEUDO_MAP` | Symbol to pseudo-class/at-rule mapping |
635
+ | `class_prefix` | `"vc"` | Prefix for generated class and variable names. Must start with a letter or underscore |
636
+ | `styles_method` | `:styles` | Name of the class macro and instance reader added by `include ViewComponent::Atomix` |
637
+ | `eager_load_paths` | `["app/components", "app/components/styles", "app/styles"]` | Directories loaded before the first request in development |
638
+ | `autoload_roots` | `["app/components/styles"]` | Directories registered with Rails as autoload roots. Set in `config/application.rb` |
639
+ | `use_layers` | `false` | Wrap output in `@layer` blocks ordered by priority |
640
+ | `vars` | `nil` | App-wide variables: a `Vars`, a callable returning one, or a constant name |
641
+
642
+ Only enable `use_layers` if your other CSS is layered too, since unlayered rules outrank every layered one.
643
+
644
+ ## Differences from StyleX
645
+
646
+ - Class names are `vc` + 12 base36 characters of a SHA1, not StyleX's murmurhash, so output is not byte-compatible with a JavaScript StyleX build.
647
+ - Top-level pseudo-class and media keys are rejected. Nest conditions under the property.
648
+ - A trait cannot be named after a CSS property or a public method of the sheet.
649
+ - Static analysis is limited to dynamic-trait property discovery. Other styles are included when their `create` calls are reached while loading `app/`; calls inside ERB templates are not.
650
+
651
+ ## Development
652
+
653
+ ```
654
+ bundle install
655
+ bundle exec rspec
656
+ bundle exec rake spec:core
657
+ bundle exec rubocop
658
+ ```
659
+
660
+ The dummy app in `spec/dummy` exercises the railtie, the inline and file modes, and the rake task.
661
+
662
+ Core specs live in `spec/core` and run with `spec_helper` without booting Rails.
663
+ Each core example gets fresh configuration, a registry, and owner/dynamic
664
+ collections; compiler specs declare their own breakpoint fixtures. Rails specs
665
+ explicitly require `rails_helper`, which boots the dummy application and keeps
666
+ failed declarations isolated from later eager loads. `rake spec` runs both
667
+ groups, and CI also runs `rake spec:core` in a separate process to check that the
668
+ core suite works independently.
669
+
670
+ Owner styles, variables, and themes use the internal `commit_owner_declaration`
671
+ boundary to commit CSS independently before publishing compiled owner results.
672
+ User blocks remain outside that boundary so successful dependencies retain
673
+ their existing lifetimes; style-specific breakpoint rollback stays with style
674
+ compilation.
675
+
676
+ `ViewComponent::Atomix.reset!` is a full reset for isolated environments: it clears registered CSS, tracked dynamic styles, configured variables, configuration values, and all style/variable/theme declarations and compiled caches on existing tracked owners. Existing accessor names remain installed, so those owners can accept new declarations. Previously returned standalone `Style`, `Sheet`, and `Vars` objects are obsolete after reset and must be recreated. Configuration object identity is preserved for Rails. Use the normal Rails reloader for development reloads instead.
677
+
678
+ A failed compilation does not publish partial CSS. Corrected declarations can be retried; separately resolved dependencies that succeeded remain registered. Declaration blocks are Ruby code: their own side effects and successful explicit calls to `create` or `define_vars` are not rolled back. Style and variable declarations retain successful earlier declarations when a later one fails.
679
+
680
+ Successful owner variable and theme declarations commit their CSS independently
681
+ of a consuming registry transaction, because their owner sheets and cached
682
+ themes survive its failure. This applies to immediate and deferred variables
683
+ and to themes with explicit or inferred variable groups. A failed owner
684
+ declaration still publishes no partial CSS. Standalone `define_vars` and
685
+ `create_theme` calls inside an explicit registry transaction retain that
686
+ transaction's rollback behavior.
687
+
688
+ Declarations added during resolution are processed before the completed result
689
+ is cached, including work added by a parent, mixin, or companion. Successful
690
+ blocks are not rerun to refresh the cache. Resolution raises `ArgumentError`
691
+ after 1,000 batches or dependency retries across the entire nested read, after
692
+ 32 unstable passes on one owner, or before exceeding 64 nested resolution/drain
693
+ frames. Recursive reads share these limits instead of starting fresh budgets.
694
+ Finite nested declarations remain supported within those bounds; after an
695
+ error, a new read receives a fresh budget and retries the remaining work.
696
+
697
+ Reentrant reads can see completed declarations while an owner is still being
698
+ evaluated. These temporary results, and results that depend on them, are not
699
+ cached as complete. Once the outer read finishes, merged contexts can be rebuilt
700
+ without rerunning successful blocks. This also keeps a failed theme's queued
701
+ declaration available for retry after a reentrant read.
702
+
703
+ Value objects own copies of their containers and strings. `Style`, `Rule`, and `Vars` expose frozen data; `Sheet` and `EnumeratedStyle` copy their maps while retaining the style objects they reference. Build a new value or merge styles instead of mutating exposed data. `Sheet#fetch` accepts the same arguments as the corresponding trait method, including rejecting arguments for static traits.
704
+
705
+ Theme blocks must return a Hash of overrides. Invalid results raise `ArgumentError` and can be retried after correction.
706
+
707
+ For contributors, declaration handling is organized by responsibility:
708
+
709
+ - `Accessors` installs readers and resolves module/companion names; `Base` resolves style declarations and inheritance. Renaming an accessor invalidates dependent sheets and themes.
710
+ - `TraitClassifier` distinguishes plain, enumerated, combination, and dynamic definitions; `Compiler` resolves references and emits CSS rules.
711
+ - `AttributeHelpers` supplies the shared `sx` and `sx_attrs` methods for components and views.
712
+ - `OwnerState` owns declaration queues and compiled caches. Reset clears declarations and caches while leaving installed accessors intact.
713
+ - `DeclarationQueue` restores failed and remaining declarations ahead of newly enqueued work. Resolvers control evaluation and transaction boundaries under the shared resolution monitor.
714
+ - `ResolutionSession` tracks provisional dependencies and shares work/depth limits across owner reads, queue drains, and the final cache rebuild.
715
+ - `ThemeResolution` combines inherited and local themes; `ThemeCompiler` registers overrides for one variable group.
716
+ - `Registry` stores registrations and commit callbacks separately. Nested transactions join their parent unless explicitly independent; callbacks run after rules are committed, outside the registry mutex.
717
+
718
+ Tests are grouped by subsystem, with focused lifecycle, transaction, and CSS-output specs for behavior spanning multiple modules.
719
+
720
+ Condition handling is shared through `ConditionResolver`: each compiler uses
721
+ one snapshot of global and component breakpoints plus pseudo shortcuts.
722
+ Standalone variables resolve conditions against the current global configuration.
723
+
724
+ Owner lifecycle calls go through named methods on `Base`. `OwnerState` holds
725
+ resettable declarations and caches; `Accessors` retains installed names and mixin
726
+ metadata across resets. An unloaded owner remains unloaded; Rails creates a new
727
+ owner when loading its constant again.
728
+
729
+ Dynamic property discovery keeps three supporting responsibilities separate:
730
+ `DynamicProperties::SourceParser` indexes lambdas,
731
+ `DynamicProperties::SourceCache` caches parsed files, and
732
+ `DynamicProperties::LiteralKey` decodes static keys while rejecting interpolation.
733
+ The literal reader reparses strings immediately before evaluating them.
734
+ Quoted symbol keys (`:'width'`, `:"width"`, `"width":`, and `%s(width)`) follow
735
+ Ruby's quoting and escape rules just like literal string keys. Interpolated
736
+ symbols and labels remain unsupported and are never evaluated during discovery.
737
+
738
+ User-facing diagnostics call declarations “traits.” Internal `sets` maps and
739
+ `set` identifiers retain their existing names for compatibility. Callable
740
+ parameters use `fn` consistently.
741
+
742
+ CI runs the same RuboCop checks as the development command above. The initial
743
+ rules cover whitespace, indentation, frozen-string headers, and explicit review
744
+ of `eval`; they do not impose method-length or DSL complexity limits.