@aihu/css-engine 0.2.4 → 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
@@ -34,38 +34,143 @@ as a per-platform `optionalDependencies` package
34
34
  resolved automatically at build time — no Rust toolchain required. In a monorepo
35
35
  dev clone the engine falls back to the workspace `target/release` binary.
36
36
 
37
- ### Usage with `viteAihuPlugin`
37
+ ### Enabling utility-class CSS in a user project
38
38
 
39
- When `@aihu/css-engine` is installed alongside `@aihu/app`, the compiler hook
40
- inside `viteAihuPlugin` automatically scans every `.aihu` SFC and folds the
41
- generated utility CSS into the build. **No additional plugin wiring is needed.**
42
-
43
- There is one configuration knob you almost certainly want: **`shadowMode: 'none'`**.
44
- Utility classes rely on the global cascade, so they must escape the per-component
45
- shadow root. Forward this through `viteAihuPlugin`'s `css` option:
39
+ Utility-class CSS is **auto-wired by presence** — install `@aihu/css-engine`
40
+ alongside `@aihu/app`, set one config knob, and every `.aihu` file in your
41
+ project is scanned and the matched rules land in your bundle's CSS asset.
42
+ There is no separate `viteCssEnginePlugin`, no `aihu.config.ts` file, no
43
+ preset selection step. The entire user surface is the `css` option on
44
+ `viteAihuPlugin()` in `vite.config.ts`:
46
45
 
47
46
  ```ts
48
- // vite.config.ts
47
+ // vite.config.ts — the entire surface
49
48
  import { viteAihuPlugin } from '@aihu/app'
50
49
  import { defineConfig } from 'vite'
51
50
 
52
51
  export default defineConfig({
53
52
  plugins: [
54
53
  viteAihuPlugin({
55
- css: { shadowMode: 'none' },
54
+ css: { shadowMode: 'none' }, // ← styles this component + external light-DOM children
56
55
  }),
57
56
  ],
58
57
  })
59
58
  ```
60
59
 
61
- If `compileSfc()` fails at build time (e.g. the native `aihu-css-core` binary
62
- is unresolvable in your install), the compiler emits a one-shot warning to the
63
- console — utility classes will not appear in the output until the binary is
64
- restored. The build itself still succeeds.
60
+ ```bash
61
+ bun add @aihu/css-engine
62
+ ```
63
+
64
+ ```jsx
65
+ // any .aihu file — classes are scanned automatically
66
+ @template {
67
+ <section class="flex gap-6 px-4 py-6">
68
+ <span class="text-lg font-semibold">hi</span>
69
+ </section>
70
+ }
71
+ ```
72
+
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`.
77
+
78
+ #### When do you need `shadowMode: 'none'`?
79
+
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.)
89
+
90
+ #### Style packs vs the utility scanner — they are separate
91
+
92
+ Two distinct things ship in `@aihu/css-engine`:
93
+
94
+ | Concern | What you do | Output |
95
+ |---|---|---|
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'`) |
97
+ | **Theme packs** (color tokens, fonts) | `import "@aihu/css-engine/styles/aihu-graphite.css"` in your entry | `--color-*` / `--font-*` CSS custom properties at `:root` |
98
+
99
+ Theme packs are plain CSS imports — they emit token variables, not utility
100
+ rules. Importing a pack alone does **not** enable the scanner; setting
101
+ `css.shadowMode: 'none'` alone does **not** import a pack. Use both for a
102
+ complete look; either independently is fine.
103
+
104
+ ### Utility vocabulary
105
+
106
+ The engine is **inspired by Tailwind v4, not a fork** — the supported set is
107
+ hand-curated in [`crates/aihu-css-core/src/tokens.rs`](crates/aihu-css-core/src/tokens.rs).
108
+
109
+ **Fixed long-tail utilities** (literal class names):
110
+
111
+ - **Display** — `block`, `inline-block`, `inline`, `flex`, `inline-flex`, `grid`, `inline-grid`, `hidden`, `contents`
112
+ - **Flexbox** — `flex-row`, `flex-col`, `flex-wrap`, `flex-nowrap`, `flex-1`, `flex-auto`, `flex-none`
113
+ - **Alignment** — `items-{start,center,end,stretch,baseline}`, `justify-{start,center,end,between,around,evenly}`
114
+ - **Position** — `static`, `relative`, `absolute`, `fixed`, `sticky`
115
+ - **Overflow** — `overflow-{hidden,auto,scroll,visible}`
116
+ - **Typography** — `text-{left,center,right,justify}`, `italic`, `not-italic`, `underline`, `line-through`, `no-underline`, `uppercase`, `lowercase`, `capitalize`, `truncate`
117
+ - **Font weight** — `font-{thin,normal,medium,semibold,bold,black}`
118
+ - **Borders / radius** — `border`, `rounded`, `rounded-{none,sm,md,lg,xl,2xl,full}`
119
+ - **Shadows** — `shadow`, `shadow-{sm,md,lg,none}`
120
+ - **Sizing keywords** — `w-{full,screen,auto}`, `h-{full,screen,auto}`
121
+
122
+ **Parameterized utilities** (`<prefix>-<value>`):
123
+
124
+ | Prefix family | Examples | Notes |
125
+ |---|---|---|
126
+ | Spacing — `p`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml`, `gap`, `gap-x`, `gap-y` | `p-4`, `mx-auto`, `gap-6` | value × `0.25rem` |
127
+ | Sizing — `w`, `h`, `min-w`, `max-w`, `min-h`, `max-h` | `w-64`, `max-w-7xl` | spacing scale or fractions |
128
+ | Font size — `text-{xs,sm,base,lg,xl,2xl,3xl,…}` | `text-lg` | includes paired line-height |
129
+ | z-index — `z-{N}` | `z-10`, `z-50` | numeric only |
130
+ | Opacity — `opacity-{N}` | `opacity-75` | 0–100 |
131
+ | Palette colors — `bg`, `text`, `border`, `fill`, `stroke`, `ring`, `outline` followed by a named keyword | `bg-red-500`, `text-slate-700` | |
132
+
133
+ **Arbitrary values** (`<prefix>-[<value>]` — emitted verbatim into the mapped
134
+ property): `bg-[#1a1d24]`, `w-[34ch]`, `text-[14px]`, `px-[2rem]`, etc.
135
+ Supported prefixes: `bg`, `text`, `w`, `h`, `min-w`, `max-w`, `min-h`, `max-h`,
136
+ `p`, `px`, `py`, `m`, `mx`, `my`, `gap`, `rounded`, `border`, `leading`,
137
+ `tracking`, `z`, `top`/`right`/`bottom`/`left`/`inset`, `fill`, `stroke`,
138
+ `shadow`. Underscores in the bracket become spaces (Tailwind convention).
139
+
140
+ **Brand color utilities** — `bg-primary`, `text-accent`, `border-muted`, etc.
141
+ resolve to `var(--color-*)` registered by a `@theme` block or a theme pack.
142
+
143
+ #### Not supported (common misses — call out to avoid surprises)
144
+
145
+ The vocabulary is intentionally narrower than full Tailwind. The following are
146
+ **not** in the table; use them and you'll get the class string in your HTML
147
+ with **no matching CSS rule** (the scanner returns `None` and emits nothing):
148
+
149
+ - `grid-cols-N` / `grid-rows-N` / `col-span-N` / `row-span-N` — **no grid template utilities**. Use `display: grid` via the `grid` class, then write a hand-authored `@style` block or an arbitrary `style="grid-template-columns:…"` attribute.
150
+ - `mx-auto` / `my-auto` — the spacing scale is `value × 0.25rem` and doesn't include the `auto` keyword. Use `style="margin-inline: auto"` or an arbitrary value (`mx-[auto]`) instead.
151
+ - `max-w-Nxl` / `max-w-screen` / etc. — `max-w` parameterized values follow the spacing/sizing scale, not Tailwind's named breakpoint scale. `max-w-[1280px]` (arbitrary value) works; `max-w-7xl` does not.
152
+ - Tailwind v4 colors *outside* the named keyword set (e.g. `text-zinc-200` — depends on whether `zinc` is in `named_keyword_color`)
153
+ - Responsive prefixes (`sm:`, `md:`, `lg:`, `xl:`, `2xl:`) — **not yet handled** by the scanner
154
+ - State variants outside the WC-native set (`host:`, `slotted:`, `part-*:` are supported per the variants section above; arbitrary `hover:`, `focus:`, `dark:` etc. depend on what the current `parse_variant` accepts)
155
+ - Container queries, `@layer` ordering, JIT-style arbitrary properties (`[mask-image:url(...)]`)
156
+
157
+ **The authoritative source is [`crates/aihu-css-core/src/tokens.rs`](crates/aihu-css-core/src/tokens.rs)** — the lists above are a summary of that file's match arms and may drift; when in doubt, grep that file or write a one-liner test against `utility_to_css(class_name)`.
158
+
159
+ #### Production caveat — native binary must resolve
160
+
161
+ The scanner runs through the prebuilt `aihu-css-compile` Rust binary. For npm
162
+ consumers the binary ships as a per-platform `optionalDependencies` package
163
+ (e.g. `@aihu/css-engine-darwin-arm64`) and is auto-resolved by your package
164
+ manager. If resolution fails (offline install, unsupported platform), the
165
+ compiler emits a one-shot console warning, the build succeeds, and utility
166
+ rules are silently omitted from the bundle CSS. In monorepo dev clones the
167
+ fallback is `target/release/aihu-css-compile` (run `cargo build --release -p
168
+ aihu-css-core`).
65
169
 
66
170
  See [`examples/css-engine-utility/`](../../examples/css-engine-utility) for a
67
- minimal end-to-end demonstration, including an acceptance script that greps
68
- the built CSS for the expected `.flex { display: flex }` rule.
171
+ minimal end-to-end demonstration, including `scripts/check-utility-css.ts`
172
+ which greps the built CSS asset for the expected `.flex{display:flex}` rule
173
+ a useful pattern to copy into your own project's CI.
69
174
 
70
175
  ### Local development
71
176
 
@@ -93,7 +198,7 @@ npm install @aihu/css-engine
93
198
  bun add @aihu/css-engine
94
199
  ```
95
200
 
96
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.4`.</i></sub>
201
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
97
202
 
98
203
  <!-- END_AUTOGEN: install -->
99
204
 
@@ -104,12 +209,12 @@ bun add @aihu/css-engine
104
209
 
105
210
  | | |
106
211
  |---|---|
107
- | **Version** | `0.2.4` |
212
+ | **Version** | `0.3.0` |
108
213
  | **Tier** | D — Compiler — CSS engine (Tailwind v4 hard fork, WC-native scoped output) |
109
214
  | **Published files** | 5 entries |
110
215
  | **License** | MIT |
111
216
 
112
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.4`.</i></sub>
217
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
113
218
 
114
219
  <!-- END_AUTOGEN: stats -->
115
220
 
@@ -128,7 +233,7 @@ bun add @aihu/css-engine
128
233
  | `./runtime/cn` | `./dist/runtime/cn.js` | `—` |
129
234
  | `./runtime/progressive` | `./dist/runtime/progressive.js` | `—` |
130
235
 
131
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.4`.</i></sub>
236
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
132
237
 
133
238
  <!-- END_AUTOGEN: exports -->
134
239
 
@@ -139,7 +244,7 @@ bun add @aihu/css-engine
139
244
 
140
245
  **Dependencies:**
141
246
 
142
- - `@aihu/compiler` — `workspace:*`
247
+ - `@aihu/compiler` — `workspace:^`
143
248
 
144
249
  **Optional dependencies (platform-specific):**
145
250
 
@@ -148,7 +253,7 @@ bun add @aihu/css-engine
148
253
  - `@aihu/css-engine-linux-x64-gnu` — `0.1.2`
149
254
  - `@aihu/css-engine-win32-x64-msvc` — `0.1.2`
150
255
 
151
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.4`.</i></sub>
256
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
152
257
 
153
258
  <!-- END_AUTOGEN: deps -->
154
259
 
@@ -161,7 +266,7 @@ bun add @aihu/css-engine
161
266
  - [@aihu/compiler](../compiler)
162
267
  - [Aihu framework root](../../README.md)
163
268
 
164
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.4`.</i></sub>
269
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
165
270
 
166
271
  <!-- END_AUTOGEN: see-also -->
167
272
 
@@ -172,6 +277,6 @@ bun add @aihu/css-engine
172
277
 
173
278
  MIT — see [LICENSE](../../LICENSE).
174
279
 
175
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.4`.</i></sub>
280
+ <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
176
281
 
177
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 {