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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +744 -0
- data/lib/view_component/atomix/accessors.rb +129 -0
- data/lib/view_component/atomix/attribute_helpers.rb +15 -0
- data/lib/view_component/atomix/attrs.rb +17 -0
- data/lib/view_component/atomix/base.rb +226 -0
- data/lib/view_component/atomix/builder.rb +77 -0
- data/lib/view_component/atomix/compiler.rb +147 -0
- data/lib/view_component/atomix/condition_resolver.rb +79 -0
- data/lib/view_component/atomix/configuration.rb +103 -0
- data/lib/view_component/atomix/declaration_queue.rb +50 -0
- data/lib/view_component/atomix/dynamic_properties/literal_key.rb +68 -0
- data/lib/view_component/atomix/dynamic_properties/source_cache.rb +31 -0
- data/lib/view_component/atomix/dynamic_properties/source_parser.rb +61 -0
- data/lib/view_component/atomix/dynamic_properties.rb +75 -0
- data/lib/view_component/atomix/dynamic_style.rb +109 -0
- data/lib/view_component/atomix/enumerated_style.rb +39 -0
- data/lib/view_component/atomix/hashing.rb +47 -0
- data/lib/view_component/atomix/helper.rb +31 -0
- data/lib/view_component/atomix/immutable.rb +20 -0
- data/lib/view_component/atomix/known_properties.rb +102 -0
- data/lib/view_component/atomix/owner_state.rb +48 -0
- data/lib/view_component/atomix/property.rb +183 -0
- data/lib/view_component/atomix/railtie.rb +46 -0
- data/lib/view_component/atomix/registry.rb +208 -0
- data/lib/view_component/atomix/resolution_session.rb +81 -0
- data/lib/view_component/atomix/rule.rb +42 -0
- data/lib/view_component/atomix/sheet.rb +98 -0
- data/lib/view_component/atomix/style.rb +80 -0
- data/lib/view_component/atomix/tasks/build.rake +17 -0
- data/lib/view_component/atomix/theme_compiler.rb +19 -0
- data/lib/view_component/atomix/theme_resolution.rb +66 -0
- data/lib/view_component/atomix/trait_classifier.rb +111 -0
- data/lib/view_component/atomix/vars.rb +97 -0
- data/lib/view_component/atomix/version.rb +7 -0
- data/lib/view_component/atomix/weak_collection.rb +62 -0
- data/lib/view_component/atomix.rb +301 -0
- data/lib/view_component_atomix.rb +3 -0
- 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.
|