@aihu/css-engine 0.2.5 → 0.3.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.
package/README.md CHANGED
@@ -51,7 +51,7 @@ import { defineConfig } from 'vite'
51
51
  export default defineConfig({
52
52
  plugins: [
53
53
  viteAihuPlugin({
54
- css: { shadowMode: 'none' }, // ← REQUIRED for utility classes
54
+ css: { shadowMode: 'none' }, // ← styles this component + external light-DOM children
55
55
  }),
56
56
  ],
57
57
  })
@@ -70,17 +70,22 @@ bun add @aihu/css-engine
70
70
  }
71
71
  ```
72
72
 
73
- That's it. After `bun run build`, `dist/assets/index-*.css` contains the
74
- emitted rules (`.flex{display:flex}`, `.gap-6{gap:1.5rem}`, etc.).
73
+ That's it. With the default `shadowMode: 'open'`, the scoped rules
74
+ (`.flex{display:flex}`, `.gap-6{gap:1.5rem}`, etc.) fold into each component's
75
+ shadow `<style>`. With `shadowMode: 'none'` (the example above) they instead
76
+ land in `dist/assets/index-*.css` after `bun run build`.
75
77
 
76
- #### Why `shadowMode: 'none'`?
78
+ #### When do you need `shadowMode: 'none'`?
77
79
 
78
- Utility classes rely on the global cascade. With the default `shadowMode: 'open'`
79
- each component lives behind its own shadow root, so global rules can't reach
80
- template elements. Switching to `'none'` mounts components without a shadow
81
- root and lets the bundled CSS reach them. Pick `'open'` or `'closed'` only if
82
- you're authoring components with hand-written `@style { ... }` blocks instead
83
- of utility classes.
80
+ Scoped utility classes work fine behind a shadow root (the default `'open'`):
81
+ `@aihu/css-engine` compiles each SFC's classes to a per-component stylesheet and
82
+ folds it into that component's shadow `<style>`. It is scoped by design and does
83
+ **not** rely on the global cascade.
84
+
85
+ Use `'none'` only when you want the utility CSS in the light DOM — for example to
86
+ style external / slotted child elements that live outside your component's shadow
87
+ root, or to emit a single global sheet. (Truly global frameworks like Tailwind,
88
+ UnoCSS, or Pico do require `'none'`, but `@aihu/css-engine` does not.)
84
89
 
85
90
  #### Style packs vs the utility scanner — they are separate
86
91
 
@@ -88,7 +93,7 @@ Two distinct things ship in `@aihu/css-engine`:
88
93
 
89
94
  | Concern | What you do | Output |
90
95
  |---|---|---|
91
- | **Utility scanner** (this section) | Install package + set `css.shadowMode: 'none'` | Per-class rules folded into the Vite CSS bundle |
96
+ | **Utility scanner** (this section) | Install the package (works in any shadow mode; default `'open'` folds into the shadow style) | Per-component scoped rules (or the Vite CSS bundle under `'none'`) |
92
97
  | **Theme packs** (color tokens, fonts) | `import "@aihu/css-engine/styles/aihu-graphite.css"` in your entry | `--color-*` / `--font-*` CSS custom properties at `:root` |
93
98
 
94
99
  Theme packs are plain CSS imports — they emit token variables, not utility
@@ -193,7 +198,7 @@ npm install @aihu/css-engine
193
198
  bun add @aihu/css-engine
194
199
  ```
195
200
 
196
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
201
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
197
202
 
198
203
  <!-- END_AUTOGEN: install -->
199
204
 
@@ -204,12 +209,12 @@ bun add @aihu/css-engine
204
209
 
205
210
  | | |
206
211
  |---|---|
207
- | **Version** | `0.2.5` |
212
+ | **Version** | `0.3.0` |
208
213
  | **Tier** | D — Compiler — CSS engine (Tailwind v4 hard fork, WC-native scoped output) |
209
214
  | **Published files** | 5 entries |
210
215
  | **License** | MIT |
211
216
 
212
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
217
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
213
218
 
214
219
  <!-- END_AUTOGEN: stats -->
215
220
 
@@ -228,7 +233,7 @@ bun add @aihu/css-engine
228
233
  | `./runtime/cn` | `./dist/runtime/cn.js` | `—` |
229
234
  | `./runtime/progressive` | `./dist/runtime/progressive.js` | `—` |
230
235
 
231
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
236
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
232
237
 
233
238
  <!-- END_AUTOGEN: exports -->
234
239
 
@@ -248,7 +253,7 @@ bun add @aihu/css-engine
248
253
  - `@aihu/css-engine-linux-x64-gnu` — `0.1.2`
249
254
  - `@aihu/css-engine-win32-x64-msvc` — `0.1.2`
250
255
 
251
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
256
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
252
257
 
253
258
  <!-- END_AUTOGEN: deps -->
254
259
 
@@ -261,7 +266,7 @@ bun add @aihu/css-engine
261
266
  - [@aihu/compiler](../compiler)
262
267
  - [Aihu framework root](../../README.md)
263
268
 
264
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
269
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
265
270
 
266
271
  <!-- END_AUTOGEN: see-also -->
267
272
 
@@ -272,6 +277,6 @@ bun add @aihu/css-engine
272
277
 
273
278
  MIT — see [LICENSE](../../LICENSE).
274
279
 
275
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
280
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
276
281
 
277
282
  <!-- END_AUTOGEN: license -->
@@ -20,8 +20,8 @@ use crate::ast::{SfcAst, SfcStyleScope};
20
20
  use crate::progressive::ProgressiveRegistry;
21
21
  use crate::scanner::{scan, ScanResult};
22
22
  use crate::theme::{extract_theme_blocks, ThemeRegistry};
23
- use crate::tokens::utility_to_css;
24
- use crate::variants::{split_variants, Variant};
23
+ use crate::tokens::{animation_keyframes, utility_to_css};
24
+ use crate::variants::{split_variants, AttrMatch, Variant};
25
25
 
26
26
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27
27
  pub enum OutputMode {
@@ -33,7 +33,10 @@ pub enum OutputMode {
33
33
  fn escape_class(class: &str) -> String {
34
34
  let mut out = String::with_capacity(class.len() + 4);
35
35
  for c in class.chars() {
36
- if matches!(c, '[' | ']' | '#' | '(' | ')' | '.' | '%' | '/' | ':' | ',') {
36
+ if matches!(
37
+ c,
38
+ '[' | ']' | '#' | '(' | ')' | '.' | '%' | '/' | ':' | ',' | '@' | '=' | '"'
39
+ ) {
37
40
  out.push('\\');
38
41
  }
39
42
  out.push(c);
@@ -71,7 +74,11 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
71
74
 
72
75
  // The base (innermost) selector and declaration body.
73
76
  let mut selector = class_sel;
74
- let mut media: Option<String> = None;
77
+ // Wrapping at-rule (e.g. `@media (min-width: …)` for breakpoints or
78
+ // `@container (min-width: …)` for container queries). Generalized from the
79
+ // old `media: Option<String>` slot so both `@media` and `@container` wrap
80
+ // the rule uniformly: `<at-rule> { <rule> }`.
81
+ let mut at_rule: Option<String> = None;
75
82
  let mut dark_cascade = false;
76
83
 
77
84
  for v in &variants {
@@ -85,9 +92,43 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
85
92
  // `[&>div]:` → substitute `&` for the base selector.
86
93
  selector = sel.replace('&', &selector);
87
94
  }
95
+ Variant::Group(Some(state)) => {
96
+ // `group-hover:bg-x` → `.group:hover .group-hover\:bg-x`.
97
+ // Prepend a descendant-combinator ancestor selector: the rule
98
+ // applies to the element bearing this class when an ancestor
99
+ // marked `class="group"` is in `:<state>`. Within a shadow root
100
+ // both the marker and the styled element live in the same tree,
101
+ // so the class selectors match per spec §6.3 scoping.
102
+ selector = format!(".group:{state} {selector}");
103
+ }
104
+ Variant::Peer(Some(state)) => {
105
+ // `peer-checked:bg-x` → `.peer:checked ~ .peer-checked\:bg-x`.
106
+ // Prepend a subsequent-sibling-combinator selector: the rule
107
+ // applies when a PRIOR sibling marked `class="peer"` is in
108
+ // `:<state>`. CSS can only look backward to earlier siblings,
109
+ // so `peer` must appear before the styled element in source.
110
+ selector = format!(".peer:{state} ~ {selector}");
111
+ }
112
+ // Bare `group`/`peer` never reach here (they are marker utilities,
113
+ // not variant prefixes); a `None` state is unreachable but handled
114
+ // defensively as a no-op so the base selector is emitted unchanged.
115
+ Variant::Group(None) | Variant::Peer(None) => {}
116
+ // aria-*/data-* attribute variants compile to an attribute selector
117
+ // appended to the base: `aria-checked:` → `.cls[aria-checked="true"]`,
118
+ // `data-[state=open]:` → `.cls[data-state="open"]`. A keyword data-*
119
+ // (`data-active:`) emits a presence selector `[data-active]`.
120
+ Variant::Aria(m) => selector = format!("{selector}{}", attr_selector("aria", m)),
121
+ Variant::Data(m) => selector = format!("{selector}{}", attr_selector("data", m)),
88
122
  Variant::Breakpoint(bp) => {
89
123
  if let Some(min) = theme.breakpoint(bp) {
90
- media = Some(format!("(min-width: {min})"));
124
+ at_rule = Some(format!("@media (min-width: {min})"));
125
+ }
126
+ }
127
+ // Container queries wrap the rule in an `@container` at-rule keyed on
128
+ // the container breakpoint scale (mirrors `breakpoint()`).
129
+ Variant::Container(bp) => {
130
+ if let Some(min) = theme.container_breakpoint(bp) {
131
+ at_rule = Some(format!("@container (min-width: {min})"));
91
132
  }
92
133
  }
93
134
  Variant::Dark | Variant::HostContextDark => {
@@ -112,12 +153,41 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
112
153
  format!("{selector} {{ {body} }}\n")
113
154
  };
114
155
 
115
- Some(match media {
116
- Some(q) => format!("@media {q} {{\n{rule}}}\n"),
156
+ let rule = match at_rule {
157
+ Some(at) => format!("{at} {{\n{rule}}}\n"),
158
+ None => rule,
159
+ };
160
+
161
+ // Hoist the @keyframes an `animate-*` utility depends on as a top-level
162
+ // sibling rule (it cannot live nested inside the selector body). Re-emitting
163
+ // an identical block is idempotent in CSS, so per-occurrence emission is
164
+ // safe. `base` is the variant-stripped class (e.g. `animate-spin`).
165
+ Some(match animation_keyframes(&base) {
166
+ Some(kf) => format!("{rule}{kf}\n"),
117
167
  None => rule,
118
168
  })
119
169
  }
120
170
 
171
+ /// Build an attribute-selector fragment for an `aria-*`/`data-*` variant.
172
+ ///
173
+ /// `attr_selector("aria", Name{checked, true})` → `[aria-checked="true"]`;
174
+ /// `attr_selector("data", NameValue{state, open})` → `[data-state="open"]`;
175
+ /// `attr_selector("data", Name{active, false})` → `[data-active]` (presence).
176
+ fn attr_selector(family: &str, m: &AttrMatch) -> String {
177
+ match m {
178
+ AttrMatch::Name { name, imply_true } => {
179
+ if *imply_true {
180
+ format!("[{family}-{name}=\"true\"]")
181
+ } else {
182
+ format!("[{family}-{name}]")
183
+ }
184
+ }
185
+ AttrMatch::NameValue { name, value } => {
186
+ format!("[{family}-{name}=\"{value}\"]")
187
+ }
188
+ }
189
+ }
190
+
121
191
  /// Emit CSS for a scanned utility set in the given mode.
122
192
  pub fn emit(result: &ScanResult, theme: &ThemeRegistry, mode: OutputMode) -> String {
123
193
  emit_with_progressive(result, theme, &ProgressiveRegistry::with_builtins(), mode)
@@ -138,6 +208,10 @@ pub fn emit_with_progressive(
138
208
  // Flat back-compat: only plain utilities, no variant wrapping.
139
209
  if let Some(body) = utility_to_css(token) {
140
210
  out.push_str(&format!(".{token} {{ {body} }}\n"));
211
+ if let Some(kf) = animation_keyframes(token) {
212
+ out.push_str(kf);
213
+ out.push('\n');
214
+ }
141
215
  }
142
216
  }
143
217
  OutputMode::Scoped => {
@@ -172,7 +246,12 @@ pub fn emit_sfc_scoped(ast: &SfcAst) -> String {
172
246
  out.push_str(&theme.emit_host_tokens());
173
247
 
174
248
  // 2. Scanned utility rules (scoped) — progressive prefixes routed via `prog`.
175
- out.push_str(&emit_with_progressive(&result, &theme, &prog, OutputMode::Scoped));
249
+ out.push_str(&emit_with_progressive(
250
+ &result,
251
+ &theme,
252
+ &prog,
253
+ OutputMode::Scoped,
254
+ ));
176
255
 
177
256
  // 3. Fold the authored @style block (minus @theme directives).
178
257
  if let Some(style) = &ast.style {
@@ -42,6 +42,11 @@ pub fn compile_classes(classes: &[String]) -> String {
42
42
  output.push_str(" { ");
43
43
  output.push_str(&body);
44
44
  output.push_str(" }\n");
45
+ // Hoist the matching @keyframes as a sibling rule (animate-* only).
46
+ if let Some(kf) = tokens::animation_keyframes(class) {
47
+ output.push_str(kf);
48
+ output.push('\n');
49
+ }
45
50
  }
46
51
  }
47
52
  output
@@ -70,6 +70,20 @@ impl ThemeRegistry {
70
70
  }
71
71
  }
72
72
 
73
+ /// Resolve a container-query breakpoint (`@sm`/`@md`/…) to its min-width.
74
+ /// Mirrors [`breakpoint`] but uses Tailwind's container-query scale (which
75
+ /// differs from the viewport breakpoint scale): `@sm`=24rem … `@2xl`=42rem.
76
+ pub fn container_breakpoint(&self, name: &str) -> Option<&'static str> {
77
+ match name {
78
+ "sm" => Some("24rem"),
79
+ "md" => Some("28rem"),
80
+ "lg" => Some("32rem"),
81
+ "xl" => Some("36rem"),
82
+ "2xl" => Some("42rem"),
83
+ _ => None,
84
+ }
85
+ }
86
+
73
87
  /// Merge an `@theme { ... }` block's tokens over the current registry.
74
88
  /// Returns the number of tokens registered/overridden.
75
89
  pub fn apply_theme_block(&mut self, theme_body: &str) -> usize {