@aihu/css-engine 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.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/crates/aihu-css-core/Cargo.toml +22 -0
- package/crates/aihu-css-core/src/ast.rs +173 -0
- package/crates/aihu-css-core/src/bin/main.rs +73 -0
- package/crates/aihu-css-core/src/cache.rs +182 -0
- package/crates/aihu-css-core/src/emit.rs +236 -0
- package/crates/aihu-css-core/src/features/anchor.rs +41 -0
- package/crates/aihu-css-core/src/features/mod.rs +33 -0
- package/crates/aihu-css-core/src/features/popover.rs +40 -0
- package/crates/aihu-css-core/src/features/text_balance.rs +36 -0
- package/crates/aihu-css-core/src/features/view_transition.rs +38 -0
- package/crates/aihu-css-core/src/lib.rs +67 -0
- package/crates/aihu-css-core/src/progressive.rs +200 -0
- package/crates/aihu-css-core/src/scanner.rs +235 -0
- package/crates/aihu-css-core/src/theme.rs +179 -0
- package/crates/aihu-css-core/src/tokens.rs +470 -0
- package/crates/aihu-css-core/src/variants.rs +124 -0
- package/crates/aihu-css-core/tests/cache.rs +71 -0
- package/crates/aihu-css-core/tests/emit.rs +148 -0
- package/crates/aihu-css-core/tests/fixtures/button.ast.json +19 -0
- package/crates/aihu-css-core/tests/progressive_snapshot.rs +102 -0
- package/crates/aihu-css-core/tests/scanner.rs +99 -0
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +73 -0
- package/crates/aihu-css-core/tests/snapshot.rs +24 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__flat_output_for_class_list.snap +6 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +24 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +33 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +45 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +28 -0
- package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_basic_class.snap +5 -0
- package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_multiple_classes.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__arbitrary_values.snap +9 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_borders.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_colors.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_effects.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_layout.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_spacing.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_typography.snap +11 -0
- package/crates/aihu-css-core/tests/tokens.rs +79 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/cn.d.ts +14 -0
- package/dist/runtime/cn.d.ts.map +1 -0
- package/dist/runtime/cn.js +107 -0
- package/dist/runtime/cn.js.map +1 -0
- package/dist/runtime/progressive.d.ts +54 -0
- package/dist/runtime/progressive.d.ts.map +1 -0
- package/dist/runtime/progressive.js +132 -0
- package/dist/runtime/progressive.js.map +1 -0
- package/package.json +54 -0
- package/styles/aihu-default.css +73 -0
- package/styles/aihu-graphite.css +71 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
//! `emit.rs` — scoped-output emitter (Plan 2 Tasks 4, 5, 6).
|
|
2
|
+
//!
|
|
3
|
+
//! Turns a scanned utility set into CSS. Two modes:
|
|
4
|
+
//!
|
|
5
|
+
//! - [`OutputMode::Flat`] — Plan 1 back-compat: `.class { … }` global-ish rules.
|
|
6
|
+
//! - [`OutputMode::Scoped`] — the new default for `compile_sfc`. Every rule
|
|
7
|
+
//! lives inside the SFC's shadow root (the compiler folds the output into the
|
|
8
|
+
//! component's `<style>`), so there is NO global utility stylesheet. Class
|
|
9
|
+
//! selectors inside a shadow `<style>` only match that shadow tree — that IS
|
|
10
|
+
//! the scoping mechanism (per spec §6.3). We also fold the authored `@style`
|
|
11
|
+
//! block (scoped folded in; `$global` passed through) and the theme tokens.
|
|
12
|
+
//!
|
|
13
|
+
//! Variant resolution (Tasks 5/6) happens here: each scanned token is split via
|
|
14
|
+
//! `variants::split_variants`, the base utility is compiled via `tokens`, then
|
|
15
|
+
//! the variants wrap/append to the selector. Dark-mode variants (`dark:`,
|
|
16
|
+
//! `host-context-dark:`) emit a custom-property cascade — NEVER
|
|
17
|
+
//! `:host-context()` (Firefox workaround, `decision-firefox-host-context-workaround`).
|
|
18
|
+
|
|
19
|
+
use crate::ast::{SfcAst, SfcStyleScope};
|
|
20
|
+
use crate::progressive::ProgressiveRegistry;
|
|
21
|
+
use crate::scanner::{scan, ScanResult};
|
|
22
|
+
use crate::theme::{extract_theme_blocks, ThemeRegistry};
|
|
23
|
+
use crate::tokens::utility_to_css;
|
|
24
|
+
use crate::variants::{split_variants, Variant};
|
|
25
|
+
|
|
26
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
27
|
+
pub enum OutputMode {
|
|
28
|
+
Flat,
|
|
29
|
+
Scoped,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// CSS-escape a class name for use in a selector (`bg-[#fff]` → `bg-\[\#fff\]`).
|
|
33
|
+
fn escape_class(class: &str) -> String {
|
|
34
|
+
let mut out = String::with_capacity(class.len() + 4);
|
|
35
|
+
for c in class.chars() {
|
|
36
|
+
if matches!(c, '[' | ']' | '#' | '(' | ')' | '.' | '%' | '/' | ':' | ',') {
|
|
37
|
+
out.push('\\');
|
|
38
|
+
}
|
|
39
|
+
out.push(c);
|
|
40
|
+
}
|
|
41
|
+
out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// If `token`'s leading prefix names a registered progressive feature, return
|
|
45
|
+
/// its `(prefix, base)` split. `view-transition:slide` → `("view-transition",
|
|
46
|
+
/// "slide")`; `text-balance:` → `("text-balance", "")`.
|
|
47
|
+
fn progressive_split<'a>(token: &'a str, prog: &ProgressiveRegistry) -> Option<(&'a str, &'a str)> {
|
|
48
|
+
let colon = token.find(':')?;
|
|
49
|
+
let prefix = &token[..colon];
|
|
50
|
+
if prog.is_feature(prefix) {
|
|
51
|
+
Some((prefix, &token[colon + 1..]))
|
|
52
|
+
} else {
|
|
53
|
+
None
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Compile a single scanned token (which may carry variant prefixes) into a CSS
|
|
58
|
+
/// rule string, or `None` if the base utility is unknown.
|
|
59
|
+
///
|
|
60
|
+
/// Progressive-feature prefixes (`view-transition:`, `anchor:`, `popover:`,
|
|
61
|
+
/// `text-balance:`) are routed to the [`ProgressiveRegistry`] emitter (Plan 3
|
|
62
|
+
/// Task 4) instead of the standard selector path.
|
|
63
|
+
fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) -> Option<String> {
|
|
64
|
+
if let Some((prefix, base)) = progressive_split(token, prog) {
|
|
65
|
+
return prog.emit(prefix, base);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let (variants, base) = split_variants(token);
|
|
69
|
+
let body = utility_to_css(&base)?;
|
|
70
|
+
let class_sel = format!(".{}", escape_class(token));
|
|
71
|
+
|
|
72
|
+
// The base (innermost) selector and declaration body.
|
|
73
|
+
let mut selector = class_sel;
|
|
74
|
+
let mut media: Option<String> = None;
|
|
75
|
+
let mut dark_cascade = false;
|
|
76
|
+
|
|
77
|
+
for v in &variants {
|
|
78
|
+
match v {
|
|
79
|
+
Variant::Host => selector = format!(":host({selector})"),
|
|
80
|
+
Variant::Slotted => selector = format!("::slotted({selector})"),
|
|
81
|
+
Variant::SlottedTag(tag) => selector = format!("::slotted({tag}{selector})"),
|
|
82
|
+
Variant::Part(name) => selector = format!("::part({name})"),
|
|
83
|
+
Variant::Pseudo(pc) => selector = format!("{selector}:{pc}"),
|
|
84
|
+
Variant::ArbitrarySelector(sel) => {
|
|
85
|
+
// `[&>div]:` → substitute `&` for the base selector.
|
|
86
|
+
selector = sel.replace('&', &selector);
|
|
87
|
+
}
|
|
88
|
+
Variant::Breakpoint(bp) => {
|
|
89
|
+
if let Some(min) = theme.breakpoint(bp) {
|
|
90
|
+
media = Some(format!("(min-width: {min})"));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
Variant::Dark | Variant::HostContextDark => {
|
|
94
|
+
dark_cascade = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let rule = if dark_cascade {
|
|
100
|
+
// Firefox-safe dark cascade: gate the rule on the consumer's dark flag
|
|
101
|
+
// (a `data-theme="dark"` host attr or a `.dark` root class) rather than
|
|
102
|
+
// the host-context pseudo (unsupported in Firefox). Consumer contract:
|
|
103
|
+
// set `data-theme="dark"` on the host element OR add `.dark` to :root,
|
|
104
|
+
// and define the dark token values there. The dark variant's rule then
|
|
105
|
+
// only applies under those scopes.
|
|
106
|
+
format!(
|
|
107
|
+
"/* dark cascade (Firefox-safe; see decision-firefox-host-context-workaround) */\n\
|
|
108
|
+
:host([data-theme=\"dark\"]) {selector}, \
|
|
109
|
+
:root.dark {selector} {{ {body} }}\n"
|
|
110
|
+
)
|
|
111
|
+
} else {
|
|
112
|
+
format!("{selector} {{ {body} }}\n")
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
Some(match media {
|
|
116
|
+
Some(q) => format!("@media {q} {{\n{rule}}}\n"),
|
|
117
|
+
None => rule,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/// Emit CSS for a scanned utility set in the given mode.
|
|
122
|
+
pub fn emit(result: &ScanResult, theme: &ThemeRegistry, mode: OutputMode) -> String {
|
|
123
|
+
emit_with_progressive(result, theme, &ProgressiveRegistry::with_builtins(), mode)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// As [`emit`], but with an explicit [`ProgressiveRegistry`] (so callers can
|
|
127
|
+
/// share one registry across an SFC compile).
|
|
128
|
+
pub fn emit_with_progressive(
|
|
129
|
+
result: &ScanResult,
|
|
130
|
+
theme: &ThemeRegistry,
|
|
131
|
+
prog: &ProgressiveRegistry,
|
|
132
|
+
mode: OutputMode,
|
|
133
|
+
) -> String {
|
|
134
|
+
let mut out = String::new();
|
|
135
|
+
for token in &result.utilities {
|
|
136
|
+
match mode {
|
|
137
|
+
OutputMode::Flat => {
|
|
138
|
+
// Flat back-compat: only plain utilities, no variant wrapping.
|
|
139
|
+
if let Some(body) = utility_to_css(token) {
|
|
140
|
+
out.push_str(&format!(".{token} {{ {body} }}\n"));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
OutputMode::Scoped => {
|
|
144
|
+
if let Some(rule) = emit_token(token, theme, prog) {
|
|
145
|
+
out.push_str(&rule);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
out
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Compile a full SFC AST to scoped CSS: theme tokens (`:host`-level custom
|
|
154
|
+
/// props) + scanned utility rules + the folded authored `@style` block.
|
|
155
|
+
pub fn emit_sfc_scoped(ast: &SfcAst) -> String {
|
|
156
|
+
let mut theme = ThemeRegistry::with_aihu_defaults();
|
|
157
|
+
|
|
158
|
+
// Parse @theme directives from the authored style block first so utilities
|
|
159
|
+
// and breakpoints see overrides.
|
|
160
|
+
if let Some(style) = &ast.style {
|
|
161
|
+
let theme_bodies = extract_theme_blocks(&style.content);
|
|
162
|
+
if !theme_bodies.is_empty() {
|
|
163
|
+
theme.apply_theme_block(&theme_bodies);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let result = scan(ast);
|
|
168
|
+
let prog = ProgressiveRegistry::with_builtins();
|
|
169
|
+
let mut out = String::new();
|
|
170
|
+
|
|
171
|
+
// 1. Theme tokens at :host so var(--color-*) resolves inside the shadow.
|
|
172
|
+
out.push_str(&theme.emit_host_tokens());
|
|
173
|
+
|
|
174
|
+
// 2. Scanned utility rules (scoped) — progressive prefixes routed via `prog`.
|
|
175
|
+
out.push_str(&emit_with_progressive(&result, &theme, &prog, OutputMode::Scoped));
|
|
176
|
+
|
|
177
|
+
// 3. Fold the authored @style block (minus @theme directives).
|
|
178
|
+
if let Some(style) = &ast.style {
|
|
179
|
+
let authored = strip_theme_blocks(&style.content);
|
|
180
|
+
let authored = authored.trim();
|
|
181
|
+
if !authored.is_empty() {
|
|
182
|
+
match style.scope {
|
|
183
|
+
// Scoped: it already lives in the shadow <style>; pass through.
|
|
184
|
+
SfcStyleScope::Scoped => {
|
|
185
|
+
out.push_str("/* authored @style (scoped) */\n");
|
|
186
|
+
out.push_str(authored);
|
|
187
|
+
out.push('\n');
|
|
188
|
+
}
|
|
189
|
+
// Global ($global): passed through unscoped (edge E6). The
|
|
190
|
+
// compiler hoists this out of the shadow root.
|
|
191
|
+
SfcStyleScope::Global => {
|
|
192
|
+
out.push_str("/* authored @style ($global — unscoped) */\n");
|
|
193
|
+
out.push_str(authored);
|
|
194
|
+
out.push('\n');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Remove `@theme { ... }` blocks from style content (they become host tokens,
|
|
204
|
+
/// not raw CSS).
|
|
205
|
+
fn strip_theme_blocks(style_content: &str) -> String {
|
|
206
|
+
let mut out = String::new();
|
|
207
|
+
let mut rest = style_content;
|
|
208
|
+
while let Some(at) = rest.find("@theme") {
|
|
209
|
+
out.push_str(&rest[..at]);
|
|
210
|
+
let after = &rest[at + "@theme".len()..];
|
|
211
|
+
let Some(open) = after.find('{') else {
|
|
212
|
+
// Malformed — keep the rest verbatim and stop.
|
|
213
|
+
out.push_str(&rest[at..]);
|
|
214
|
+
return out;
|
|
215
|
+
};
|
|
216
|
+
let body_start = open + 1;
|
|
217
|
+
let mut depth = 1u32;
|
|
218
|
+
let mut end = body_start;
|
|
219
|
+
for (i, c) in after[body_start..].char_indices() {
|
|
220
|
+
match c {
|
|
221
|
+
'{' => depth += 1,
|
|
222
|
+
'}' => {
|
|
223
|
+
depth -= 1;
|
|
224
|
+
if depth == 0 {
|
|
225
|
+
end = body_start + i;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
_ => {}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
rest = &after[end + 1..];
|
|
233
|
+
}
|
|
234
|
+
out.push_str(rest);
|
|
235
|
+
out
|
|
236
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//! `anchor:` — CSS anchor positioning with a JS fallback (Plan 3 Task 6).
|
|
2
|
+
//!
|
|
3
|
+
//! Emits `anchor-name` / `position-anchor` CSS gated behind `@supports
|
|
4
|
+
//! (anchor-name: --a)`. `js_fallback()` returns `"anchorFallback"` — the
|
|
5
|
+
//! `@aihu/css-engine/runtime/progressive` shim positions the element with a
|
|
6
|
+
//! tiny hand-written floating-ui-style shim when native CSS anchor positioning
|
|
7
|
+
//! is unsupported. The shim (~2 KB) is SHARED with `popover:` (Task 7).
|
|
8
|
+
|
|
9
|
+
use crate::progressive::ProgressiveFeature;
|
|
10
|
+
|
|
11
|
+
/// `anchor:<name>` → CSS anchor-positioning, `@supports`-gated, with a JS shim.
|
|
12
|
+
pub struct Anchor;
|
|
13
|
+
|
|
14
|
+
impl ProgressiveFeature for Anchor {
|
|
15
|
+
fn prefix(&self) -> &'static str {
|
|
16
|
+
"anchor"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fn supports_condition(&self) -> Option<&'static str> {
|
|
20
|
+
Some("anchor-name: --a")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn emit_css(&self, base: &str) -> String {
|
|
24
|
+
// `anchor:tooltip` declares an anchor name + binds positioning to it.
|
|
25
|
+
let name = if base.is_empty() { "anchor" } else { base };
|
|
26
|
+
let class = if base.is_empty() {
|
|
27
|
+
"anchor".to_string()
|
|
28
|
+
} else {
|
|
29
|
+
format!("anchor\\:{base}")
|
|
30
|
+
};
|
|
31
|
+
format!(
|
|
32
|
+
".{class} {{ anchor-name: --{name}; position-anchor: --{name}; }}"
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fn js_fallback(&self) -> Option<&'static str> {
|
|
37
|
+
// Native anchor positioning is gated; when absent, the runtime shim
|
|
38
|
+
// (`anchorFallback`) positions the element with JS.
|
|
39
|
+
Some("anchorFallback")
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//! `features/` — the built-in progressive features (Plan 3 Tasks 5–8).
|
|
2
|
+
//!
|
|
3
|
+
//! Each module implements [`crate::progressive::ProgressiveFeature`] for one
|
|
4
|
+
//! forward-looking CSS feature. They are registered into the default
|
|
5
|
+
//! [`crate::progressive::ProgressiveRegistry`] via [`register_builtins`].
|
|
6
|
+
//!
|
|
7
|
+
//! | Feature | `@supports` gate | JS fallback |
|
|
8
|
+
//! |-------------------|-------------------------------|--------------------|
|
|
9
|
+
//! | `view-transition:`| `view-transition-name: none` | none (CSS-only) |
|
|
10
|
+
//! | `anchor:` | `anchor-name: --a` | `anchorFallback` |
|
|
11
|
+
//! | `popover:` | `selector(:popover-open)` | `popoverFallback` |
|
|
12
|
+
//! | `text-balance:` | none | none (CSS-only) |
|
|
13
|
+
|
|
14
|
+
use crate::progressive::ProgressiveRegistry;
|
|
15
|
+
|
|
16
|
+
pub mod anchor;
|
|
17
|
+
pub mod popover;
|
|
18
|
+
pub mod text_balance;
|
|
19
|
+
pub mod view_transition;
|
|
20
|
+
|
|
21
|
+
pub use anchor::Anchor;
|
|
22
|
+
pub use popover::Popover;
|
|
23
|
+
pub use text_balance::TextBalance;
|
|
24
|
+
pub use view_transition::ViewTransition;
|
|
25
|
+
|
|
26
|
+
/// Register every built-in progressive feature into `registry`. Called by
|
|
27
|
+
/// [`ProgressiveRegistry::with_builtins`](crate::progressive::ProgressiveRegistry::with_builtins).
|
|
28
|
+
pub fn register_builtins(registry: &mut ProgressiveRegistry) {
|
|
29
|
+
registry.register(Box::new(ViewTransition));
|
|
30
|
+
registry.register(Box::new(Anchor));
|
|
31
|
+
registry.register(Box::new(Popover));
|
|
32
|
+
registry.register(Box::new(TextBalance));
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//! `popover:` — Popover API with a portal+positioning fallback (Plan 3 Task 7).
|
|
2
|
+
//!
|
|
3
|
+
//! Emits popover CSS gated behind `@supports selector(:popover-open)`.
|
|
4
|
+
//! `js_fallback()` returns `"popoverFallback"` — the
|
|
5
|
+
//! `@aihu/css-engine/runtime/progressive` shim emulates the top layer with a
|
|
6
|
+
//! portal and positions the panel using the SAME floating-ui shim as `anchor:`
|
|
7
|
+
//! (no duplication — keeps the `progressive` sub-export under its 3 KB budget).
|
|
8
|
+
|
|
9
|
+
use crate::progressive::ProgressiveFeature;
|
|
10
|
+
|
|
11
|
+
/// `popover:<name>` → popover CSS, `@supports`-gated, with a portal JS fallback.
|
|
12
|
+
pub struct Popover;
|
|
13
|
+
|
|
14
|
+
impl ProgressiveFeature for Popover {
|
|
15
|
+
fn prefix(&self) -> &'static str {
|
|
16
|
+
"popover"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
fn supports_condition(&self) -> Option<&'static str> {
|
|
20
|
+
Some("selector(:popover-open)")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn emit_css(&self, base: &str) -> String {
|
|
24
|
+
// The popover panel's open-state styling; native top-layer when supported.
|
|
25
|
+
let class = if base.is_empty() {
|
|
26
|
+
"popover".to_string()
|
|
27
|
+
} else {
|
|
28
|
+
format!("popover\\:{base}")
|
|
29
|
+
};
|
|
30
|
+
format!(
|
|
31
|
+
".{class}:popover-open {{ position: fixed; margin: 0; inset: auto; }}"
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn js_fallback(&self) -> Option<&'static str> {
|
|
36
|
+
// When the Popover API is unavailable, the runtime shim portals the panel
|
|
37
|
+
// to the top layer and positions it with the shared floating-ui code.
|
|
38
|
+
Some("popoverFallback")
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//! `text-balance:` — the simplest possible progressive feature (Plan 3 Task 8).
|
|
2
|
+
//!
|
|
3
|
+
//! Emits a single `text-wrap: balance` declaration. `supports_condition()` is
|
|
4
|
+
//! `None` (no `@supports` gate) and `js_fallback()` is `None` (no JS): browsers
|
|
5
|
+
//! that don't understand the `balance` value silently ignore it — standard CSS
|
|
6
|
+
//! forward-compatibility. One declaration, no gate, no runtime cost.
|
|
7
|
+
|
|
8
|
+
use crate::progressive::ProgressiveFeature;
|
|
9
|
+
|
|
10
|
+
/// `text-balance:` → `text-wrap: balance`. No gate, no JS.
|
|
11
|
+
pub struct TextBalance;
|
|
12
|
+
|
|
13
|
+
impl ProgressiveFeature for TextBalance {
|
|
14
|
+
fn prefix(&self) -> &'static str {
|
|
15
|
+
"text-balance"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
fn supports_condition(&self) -> Option<&'static str> {
|
|
19
|
+
// No gate — unsupported browsers silently ignore the unknown value.
|
|
20
|
+
None
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn emit_css(&self, base: &str) -> String {
|
|
24
|
+
let class = if base.is_empty() {
|
|
25
|
+
"text-balance".to_string()
|
|
26
|
+
} else {
|
|
27
|
+
format!("text-balance\\:{base}")
|
|
28
|
+
};
|
|
29
|
+
format!(".{class} {{ text-wrap: balance; }}")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn js_fallback(&self) -> Option<&'static str> {
|
|
33
|
+
// CSS-only — no runtime fallback.
|
|
34
|
+
None
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//! `view-transition:` — the simplest progressive feature (Plan 3 Task 5).
|
|
2
|
+
//!
|
|
3
|
+
//! Emits `view-transition-name` gated behind `@supports (view-transition-name:
|
|
4
|
+
//! none)`. CSS-only: `js_fallback()` is `None`, so when the View Transitions API
|
|
5
|
+
//! is unsupported the browser silently skips the transition (no JS, no error,
|
|
6
|
+
//! no runtime cost). Per spec §6.7 this is the cheapest progressive feature.
|
|
7
|
+
|
|
8
|
+
use crate::progressive::ProgressiveFeature;
|
|
9
|
+
|
|
10
|
+
/// `view-transition:<name>` → a `view-transition-name` declaration, `@supports`-gated.
|
|
11
|
+
pub struct ViewTransition;
|
|
12
|
+
|
|
13
|
+
impl ProgressiveFeature for ViewTransition {
|
|
14
|
+
fn prefix(&self) -> &'static str {
|
|
15
|
+
"view-transition"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
fn supports_condition(&self) -> Option<&'static str> {
|
|
19
|
+
Some("view-transition-name: none")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fn emit_css(&self, base: &str) -> String {
|
|
23
|
+
// `view-transition:hero` → `.view-transition\:hero { view-transition-name: hero; }`
|
|
24
|
+
// An empty base (`view-transition:`) defaults to `auto`.
|
|
25
|
+
let name = if base.is_empty() { "auto" } else { base };
|
|
26
|
+
let class = if base.is_empty() {
|
|
27
|
+
"view-transition".to_string()
|
|
28
|
+
} else {
|
|
29
|
+
format!("view-transition\\:{base}")
|
|
30
|
+
};
|
|
31
|
+
format!(".{class} {{ view-transition-name: {name}; }}")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn js_fallback(&self) -> Option<&'static str> {
|
|
35
|
+
// CSS-only — unsupported browsers silently skip the transition.
|
|
36
|
+
None
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//! aihu-css-core — CSS engine bootstrap.
|
|
2
|
+
//!
|
|
3
|
+
//! See `docs/superpowers/specs/2026-05-10-aihu-css-engine-and-primitives-design.md`
|
|
4
|
+
//! for the full design. This bootstrap implementation supports a fixed subset
|
|
5
|
+
//! of utility classes (see tokens.rs); Plan 2 wires the AST scanner; Plan 3
|
|
6
|
+
//! adds variants and progressive features.
|
|
7
|
+
|
|
8
|
+
pub mod ast;
|
|
9
|
+
pub mod cache;
|
|
10
|
+
pub mod emit;
|
|
11
|
+
pub mod features;
|
|
12
|
+
pub mod progressive;
|
|
13
|
+
pub mod scanner;
|
|
14
|
+
pub mod theme;
|
|
15
|
+
pub mod tokens;
|
|
16
|
+
pub mod variants;
|
|
17
|
+
|
|
18
|
+
pub use ast::{parse_ast, AstError, SfcAst, SfcAttr, SfcNode, SfcStyleScope};
|
|
19
|
+
pub use cache::{hash_ast, CssCache};
|
|
20
|
+
pub use emit::{emit, emit_sfc_scoped, OutputMode};
|
|
21
|
+
pub use progressive::{ProgressiveFeature, ProgressiveRegistry};
|
|
22
|
+
pub use scanner::{scan, scan_ast, ScanResult};
|
|
23
|
+
pub use theme::ThemeRegistry;
|
|
24
|
+
pub use variants::{split_variants, Variant};
|
|
25
|
+
|
|
26
|
+
/// Compile a list of utility class names into CSS rules.
|
|
27
|
+
/// Each known class becomes `.class-name { <body> }`. Unknown classes are skipped.
|
|
28
|
+
///
|
|
29
|
+
/// # Example
|
|
30
|
+
/// ```
|
|
31
|
+
/// use aihu_css_core::compile_classes;
|
|
32
|
+
/// let css = compile_classes(&["bg-primary".to_string(), "p-4".to_string()]);
|
|
33
|
+
/// assert!(css.contains(".bg-primary"));
|
|
34
|
+
/// assert!(css.contains(".p-4"));
|
|
35
|
+
/// ```
|
|
36
|
+
pub fn compile_classes(classes: &[String]) -> String {
|
|
37
|
+
let mut output = String::new();
|
|
38
|
+
for class in classes {
|
|
39
|
+
if let Some(body) = tokens::utility_to_css(class) {
|
|
40
|
+
output.push('.');
|
|
41
|
+
output.push_str(class);
|
|
42
|
+
output.push_str(" { ");
|
|
43
|
+
output.push_str(&body);
|
|
44
|
+
output.push_str(" }\n");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
output
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Compile a parsed `.aihu` SFC AST into CSS by scanning its template for
|
|
51
|
+
/// utility classes and emitting one rule per known utility.
|
|
52
|
+
///
|
|
53
|
+
/// This is the Plan 2 flat-mode entry; [`compile_sfc_scoped`] wraps the output
|
|
54
|
+
/// in shadow-DOM scope (the new default). `compile_classes` stays for
|
|
55
|
+
/// back-compat.
|
|
56
|
+
pub fn compile_sfc(ast: &SfcAst) -> String {
|
|
57
|
+
let classes = scan_ast(ast);
|
|
58
|
+
compile_classes(&classes.into_iter().collect::<Vec<_>>())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Compile a parsed `.aihu` SFC AST into scoped, shadow-DOM-embedded CSS:
|
|
62
|
+
/// `:host`-level theme tokens, variant-resolved utility rules, and the folded
|
|
63
|
+
/// authored `@style` block. This is the production entry consumed by the TS
|
|
64
|
+
/// bridge / `aihu-css-compile --ast-json`.
|
|
65
|
+
pub fn compile_sfc_scoped(ast: &SfcAst) -> String {
|
|
66
|
+
emit_sfc_scoped(ast)
|
|
67
|
+
}
|