@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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -0
  3. package/crates/aihu-css-core/Cargo.toml +22 -0
  4. package/crates/aihu-css-core/src/ast.rs +173 -0
  5. package/crates/aihu-css-core/src/bin/main.rs +73 -0
  6. package/crates/aihu-css-core/src/cache.rs +182 -0
  7. package/crates/aihu-css-core/src/emit.rs +236 -0
  8. package/crates/aihu-css-core/src/features/anchor.rs +41 -0
  9. package/crates/aihu-css-core/src/features/mod.rs +33 -0
  10. package/crates/aihu-css-core/src/features/popover.rs +40 -0
  11. package/crates/aihu-css-core/src/features/text_balance.rs +36 -0
  12. package/crates/aihu-css-core/src/features/view_transition.rs +38 -0
  13. package/crates/aihu-css-core/src/lib.rs +67 -0
  14. package/crates/aihu-css-core/src/progressive.rs +200 -0
  15. package/crates/aihu-css-core/src/scanner.rs +235 -0
  16. package/crates/aihu-css-core/src/theme.rs +179 -0
  17. package/crates/aihu-css-core/src/tokens.rs +470 -0
  18. package/crates/aihu-css-core/src/variants.rs +124 -0
  19. package/crates/aihu-css-core/tests/cache.rs +71 -0
  20. package/crates/aihu-css-core/tests/emit.rs +148 -0
  21. package/crates/aihu-css-core/tests/fixtures/button.ast.json +19 -0
  22. package/crates/aihu-css-core/tests/progressive_snapshot.rs +102 -0
  23. package/crates/aihu-css-core/tests/scanner.rs +99 -0
  24. package/crates/aihu-css-core/tests/scoped_snapshot.rs +73 -0
  25. package/crates/aihu-css-core/tests/snapshot.rs +24 -0
  26. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +26 -0
  27. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +26 -0
  28. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +23 -0
  29. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +25 -0
  30. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__flat_output_for_class_list.snap +6 -0
  31. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +25 -0
  32. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +26 -0
  33. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +24 -0
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +33 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +45 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +28 -0
  37. package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_basic_class.snap +5 -0
  38. package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_multiple_classes.snap +8 -0
  39. package/crates/aihu-css-core/tests/snapshots/tokens__arbitrary_values.snap +9 -0
  40. package/crates/aihu-css-core/tests/snapshots/tokens__category_borders.snap +8 -0
  41. package/crates/aihu-css-core/tests/snapshots/tokens__category_colors.snap +10 -0
  42. package/crates/aihu-css-core/tests/snapshots/tokens__category_effects.snap +8 -0
  43. package/crates/aihu-css-core/tests/snapshots/tokens__category_layout.snap +12 -0
  44. package/crates/aihu-css-core/tests/snapshots/tokens__category_spacing.snap +11 -0
  45. package/crates/aihu-css-core/tests/snapshots/tokens__category_typography.snap +11 -0
  46. package/crates/aihu-css-core/tests/tokens.rs +79 -0
  47. package/dist/index.d.ts +76 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +120 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/runtime/cn.d.ts +14 -0
  52. package/dist/runtime/cn.d.ts.map +1 -0
  53. package/dist/runtime/cn.js +107 -0
  54. package/dist/runtime/cn.js.map +1 -0
  55. package/dist/runtime/progressive.d.ts +54 -0
  56. package/dist/runtime/progressive.d.ts.map +1 -0
  57. package/dist/runtime/progressive.js +132 -0
  58. package/dist/runtime/progressive.js.map +1 -0
  59. package/package.json +54 -0
  60. package/styles/aihu-default.css +73 -0
  61. package/styles/aihu-graphite.css +71 -0
@@ -0,0 +1,200 @@
1
+ //! `progressive.rs` — `ProgressiveFeature` trait + registry + `@supports` emitter.
2
+ //!
3
+ //! Plan 3 Task 4. A *progressive feature* is a forward-looking CSS feature
4
+ //! gated behind `@supports`, optionally with a JS runtime fallback. The engine
5
+ //! ships four built-ins (`view_transition`, `anchor`, `popover`, `text_balance`
6
+ //! — see `features/`), but the trait is open so additional features can be
7
+ //! registered.
8
+ //!
9
+ //! ## Fallback contract
10
+ //!
11
+ //! Each feature owns four facts:
12
+ //! - its **variant prefix** (`view-transition`, `anchor`, `popover`, `text-balance`)
13
+ //! - the **`@supports` condition** (or `None` = "always emit, silently ignored
14
+ //! if unsupported" — no gate at all)
15
+ //! - the **gated CSS** it emits for a given base utility/declaration
16
+ //! - whether it dispatches a **JS fallback** (`Some(export-name)`) or is
17
+ //! **CSS-only** (`None`)
18
+ //!
19
+ //! The emitter wraps gated CSS in `@supports (...)` when a condition is present,
20
+ //! and emits a small `/* aihu:progressive-fallback ... */` marker (read by the
21
+ //! TS layer to wire `@aihu/css-engine/runtime/progressive`) ONLY for features
22
+ //! whose `js_fallback()` is non-`None`. CSS-only features (`view-transition:`,
23
+ //! `text-balance:`) never produce a JS marker.
24
+
25
+ /// A forward-looking CSS feature gated behind `@supports`, optionally with a
26
+ /// JS runtime fallback. Each feature owns: its variant prefix, the `@supports`
27
+ /// condition, the gated CSS it emits, and whether it dispatches a JS fallback.
28
+ pub trait ProgressiveFeature {
29
+ /// The variant prefix, e.g. "view-transition", "anchor", "popover", "text-balance".
30
+ fn prefix(&self) -> &'static str;
31
+ /// The `@supports(...)` condition string, or None for "always emit, silently ignored if unsupported".
32
+ fn supports_condition(&self) -> Option<&'static str>;
33
+ /// Emit the gated CSS for a given base utility/declaration.
34
+ fn emit_css(&self, base: &str) -> String;
35
+ /// Runtime fallback descriptor: which `@aihu/css-engine/runtime/progressive`
36
+ /// export to dispatch when `@supports` fails. None = silent CSS no-op (no JS).
37
+ fn js_fallback(&self) -> Option<&'static str>;
38
+ }
39
+
40
+ /// A registry of progressive features, keyed by variant prefix. The emitter
41
+ /// consults it when it encounters a variant prefix that names a registered
42
+ /// feature, routing to the progressive emitter instead of the standard
43
+ /// selector path.
44
+ pub struct ProgressiveRegistry {
45
+ features: Vec<Box<dyn ProgressiveFeature + Send + Sync>>,
46
+ }
47
+
48
+ impl ProgressiveRegistry {
49
+ /// An empty registry.
50
+ pub fn new() -> Self {
51
+ Self {
52
+ features: Vec::new(),
53
+ }
54
+ }
55
+
56
+ /// The default registry seeded with the four built-in features. Features are
57
+ /// added to this constructor as they land (Tasks 5–8): `view-transition:`,
58
+ /// `anchor:`, `popover:`, `text-balance:`.
59
+ pub fn with_builtins() -> Self {
60
+ let mut r = Self::new();
61
+ crate::features::register_builtins(&mut r);
62
+ r
63
+ }
64
+
65
+ /// Register a feature.
66
+ pub fn register(&mut self, feature: Box<dyn ProgressiveFeature + Send + Sync>) {
67
+ self.features.push(feature);
68
+ }
69
+
70
+ /// Look up a feature by its variant prefix.
71
+ pub fn get(&self, prefix: &str) -> Option<&(dyn ProgressiveFeature + Send + Sync)> {
72
+ self.features
73
+ .iter()
74
+ .find(|f| f.prefix() == prefix)
75
+ .map(|f| f.as_ref())
76
+ }
77
+
78
+ /// True if `prefix` names a registered progressive feature.
79
+ pub fn is_feature(&self, prefix: &str) -> bool {
80
+ self.features.iter().any(|f| f.prefix() == prefix)
81
+ }
82
+
83
+ /// Emit the CSS (and optional JS-fallback marker) for a feature `prefix`
84
+ /// applied to `base`. Returns `None` if `prefix` is not registered.
85
+ ///
86
+ /// Output shape:
87
+ /// - with a `@supports` condition: `@supports (<cond>) { <css> }`
88
+ /// - without a condition: the bare `<css>` (silently ignored if unsupported)
89
+ /// - plus, ONLY when `js_fallback()` is `Some`, a trailing
90
+ /// `/* aihu:progressive-fallback <export> (when not <cond>) */` marker.
91
+ pub fn emit(&self, prefix: &str, base: &str) -> Option<String> {
92
+ let feature = self.get(prefix)?;
93
+ Some(emit_feature(feature, base))
94
+ }
95
+ }
96
+
97
+ impl Default for ProgressiveRegistry {
98
+ fn default() -> Self {
99
+ Self::with_builtins()
100
+ }
101
+ }
102
+
103
+ /// Emit the `@supports`-gated CSS (+ optional JS marker) for one feature.
104
+ pub fn emit_feature(feature: &(dyn ProgressiveFeature + Send + Sync), base: &str) -> String {
105
+ let css = feature.emit_css(base);
106
+ let mut out = match feature.supports_condition() {
107
+ Some(cond) => format!("@supports ({cond}) {{\n {css}\n}}\n"),
108
+ None => format!("{css}\n"),
109
+ };
110
+
111
+ // JS fallback marker — ONLY for features with a non-None fallback. The TS
112
+ // layer scans for this marker to wire the runtime/progressive dispatch.
113
+ if let Some(export) = feature.js_fallback() {
114
+ let cond = feature.supports_condition().unwrap_or("");
115
+ out.push_str(&format!(
116
+ "/* aihu:progressive-fallback {export} (when not @supports {cond}) */\n"
117
+ ));
118
+ }
119
+
120
+ out
121
+ }
122
+
123
+ #[cfg(test)]
124
+ mod tests {
125
+ use super::*;
126
+
127
+ // A dummy feature with a @supports gate AND a JS fallback.
128
+ struct GatedWithJs;
129
+ impl ProgressiveFeature for GatedWithJs {
130
+ fn prefix(&self) -> &'static str {
131
+ "gated"
132
+ }
133
+ fn supports_condition(&self) -> Option<&'static str> {
134
+ Some("display: grid")
135
+ }
136
+ fn emit_css(&self, base: &str) -> String {
137
+ format!(".{base} {{ display: grid; }}")
138
+ }
139
+ fn js_fallback(&self) -> Option<&'static str> {
140
+ Some("gatedFallback")
141
+ }
142
+ }
143
+
144
+ // A dummy CSS-only feature: no gate, no JS.
145
+ struct CssOnly;
146
+ impl ProgressiveFeature for CssOnly {
147
+ fn prefix(&self) -> &'static str {
148
+ "plain"
149
+ }
150
+ fn supports_condition(&self) -> Option<&'static str> {
151
+ None
152
+ }
153
+ fn emit_css(&self, base: &str) -> String {
154
+ format!(".{base} {{ color: red; }}")
155
+ }
156
+ fn js_fallback(&self) -> Option<&'static str> {
157
+ None
158
+ }
159
+ }
160
+
161
+ #[test]
162
+ fn supports_gate_wraps_css() {
163
+ let out = emit_feature(&GatedWithJs, "thing");
164
+ assert!(out.contains("@supports (display: grid)"), "gated CSS wrapped: {out}");
165
+ assert!(out.contains("display: grid;"));
166
+ }
167
+
168
+ #[test]
169
+ fn js_fallback_feature_emits_marker() {
170
+ let out = emit_feature(&GatedWithJs, "thing");
171
+ assert!(
172
+ out.contains("aihu:progressive-fallback gatedFallback"),
173
+ "non-None fallback emits a JS marker: {out}"
174
+ );
175
+ }
176
+
177
+ #[test]
178
+ fn css_only_feature_emits_no_marker_and_no_gate() {
179
+ let out = emit_feature(&CssOnly, "thing");
180
+ assert!(!out.contains("@supports"), "None condition → no @supports gate: {out}");
181
+ assert!(
182
+ !out.contains("aihu:progressive-fallback"),
183
+ "None fallback → no JS marker: {out}"
184
+ );
185
+ assert!(out.contains("color: red;"));
186
+ }
187
+
188
+ #[test]
189
+ fn registry_routes_by_prefix() {
190
+ let mut reg = ProgressiveRegistry::new();
191
+ reg.register(Box::new(GatedWithJs));
192
+ reg.register(Box::new(CssOnly));
193
+ assert!(reg.is_feature("gated"));
194
+ assert!(reg.is_feature("plain"));
195
+ assert!(!reg.is_feature("nope"));
196
+ let out = reg.emit("gated", "thing").unwrap();
197
+ assert!(out.contains("@supports"));
198
+ assert!(reg.emit("nope", "x").is_none());
199
+ }
200
+ }
@@ -0,0 +1,235 @@
1
+ //! `scanner.rs` — walks an `SfcAst` template and extracts the utility set.
2
+ //!
3
+ //! The scanner branches on the three class-forms exactly as the compiler routes
4
+ //! them (spec `22d3a66e` §3, AST-hook spec §3):
5
+ //!
6
+ //! - **`SfcAttr::Static { name: "class", value }`** (Form A) → split on ASCII
7
+ //! whitespace; every token (including variant-prefixed ones like `host:bg-x`)
8
+ //! is a candidate utility. Variant prefixes are kept verbatim so the emitter
9
+ //! (`variants.rs` / `emit.rs`) can split them at emit time.
10
+ //! - **`SfcAttr::Binding { name: "class", expr }`** (Form B) → string literals
11
+ //! embedded in the expr are extractable utilities; bare identifiers are
12
+ //! tracked in `unresolved` for `aihu css doctor` diagnostics (spec §3 edge #5).
13
+ //! - **`SfcAttr::Macro { name }`** where `name` starts with `class:` (Form C) →
14
+ //! the part after `class:` is a statically-known utility.
15
+ //! - **Any other attr** (`on:click`, `if`, `bind:value`, non-`class`) → ignored.
16
+ //! - **`MacroElement` / component nodes** → their `class` attrs are skipped
17
+ //! (edge E10: components own their own shadow scope).
18
+ //!
19
+ //! We do NOT re-parse `.aihu` source with regex (Risk #4) — only the AST.
20
+
21
+ use std::collections::BTreeSet;
22
+
23
+ use crate::ast::{SfcAst, SfcAttr, SfcNode};
24
+
25
+ /// The result of scanning an `SfcAst` template.
26
+ #[derive(Debug, Default, Clone, PartialEq, Eq)]
27
+ pub struct ScanResult {
28
+ /// Sorted, dedup'd set of utility class literals (variant prefixes intact).
29
+ pub utilities: BTreeSet<String>,
30
+ /// Identifiers / sub-expressions a `Binding` could not statically resolve
31
+ /// (e.g. `size` in `cn('btn', size)`). Surfaced for diagnostics; not
32
+ /// compiled. Deduped + sorted for determinism.
33
+ pub unresolved: BTreeSet<String>,
34
+ }
35
+
36
+ /// Convenience: scan an AST and return only the utility set (back-compat with
37
+ /// the plan's `scan_ast(&ast).contains(...)` test shape).
38
+ pub fn scan_ast(ast: &SfcAst) -> BTreeSet<String> {
39
+ scan(ast).utilities
40
+ }
41
+
42
+ /// Walk the SFC template and collect the full [`ScanResult`].
43
+ pub fn scan(ast: &SfcAst) -> ScanResult {
44
+ let mut result = ScanResult::default();
45
+ if let Some(nodes) = &ast.template {
46
+ for node in nodes {
47
+ walk(node, &mut result);
48
+ }
49
+ }
50
+ result
51
+ }
52
+
53
+ fn walk(node: &SfcNode, out: &mut ScanResult) {
54
+ match node {
55
+ SfcNode::Element { attrs, children, .. } => {
56
+ for attr in attrs {
57
+ collect_attr(attr, out);
58
+ }
59
+ for child in children {
60
+ walk(child, out);
61
+ }
62
+ }
63
+ // Edge E10: component / macroElement nodes own their own shadow scope.
64
+ // We do NOT compile their `class` attrs into the parent's sheet, but we
65
+ // still descend into children (slots may contain HTML elements).
66
+ SfcNode::MacroElement { children, .. } => {
67
+ for child in children {
68
+ walk(child, out);
69
+ }
70
+ }
71
+ SfcNode::IfBlock { branches } => {
72
+ for branch in branches {
73
+ for child in &branch.body {
74
+ walk(child, out);
75
+ }
76
+ }
77
+ }
78
+ SfcNode::EachBlock {
79
+ body, empty_body, ..
80
+ } => {
81
+ for child in body {
82
+ walk(child, out);
83
+ }
84
+ if let Some(empty) = empty_body {
85
+ for child in empty {
86
+ walk(child, out);
87
+ }
88
+ }
89
+ }
90
+ // Text / Interpolation / HtmlBlock carry no class attrs.
91
+ SfcNode::Text { .. } | SfcNode::Interpolation { .. } | SfcNode::HtmlBlock { .. } => {}
92
+ }
93
+ }
94
+
95
+ fn collect_attr(attr: &SfcAttr, out: &mut ScanResult) {
96
+ match attr {
97
+ // Form A — static class="...".
98
+ SfcAttr::Static { name, value } if name == "class" => {
99
+ for token in value.split_ascii_whitespace() {
100
+ // Interpolation placeholders like `{dynamic}` (edge E9) are not
101
+ // literal utilities — flag, don't compile.
102
+ if token.contains('{') || token.contains('}') {
103
+ out.unresolved.insert(token.to_string());
104
+ } else {
105
+ out.utilities.insert(token.to_string());
106
+ }
107
+ }
108
+ }
109
+ // Form B — $class={expr} / $class={[...]}.
110
+ SfcAttr::Binding { name, expr } if name == "class" => {
111
+ collect_binding(expr, out);
112
+ }
113
+ // Form C — $class:name={cond} → name after `class:` is the utility.
114
+ SfcAttr::Macro { name, .. } if name.starts_with("class:") => {
115
+ if let Some(class) = name.strip_prefix("class:") {
116
+ if !class.is_empty() {
117
+ out.utilities.insert(class.to_string());
118
+ }
119
+ }
120
+ }
121
+ // Any other attr (on:click, if, bind:value, non-class static/binding).
122
+ _ => {}
123
+ }
124
+ }
125
+
126
+ /// Extract utility string-literals from a `$class={expr}` expression.
127
+ ///
128
+ /// Two sub-cases (AST-hook spec §3 Form B):
129
+ /// - **Array literal** (`[...]`): walk elements; string literals are utilities,
130
+ /// non-literal elements contribute their embedded literals + their bare
131
+ /// identifiers go to `unresolved`.
132
+ /// - **Scalar** (`cn(...)`, a ternary, a bare identifier): extract embedded
133
+ /// string literals; the rest is unresolvable.
134
+ ///
135
+ /// We extract any single- or double-quoted string literal as a class token.
136
+ /// Bare identifiers (top-level, outside a string) are recorded in `unresolved`.
137
+ fn collect_binding(expr: &str, out: &mut ScanResult) {
138
+ let literals = extract_string_literals(expr);
139
+ let had_literals = !literals.is_empty();
140
+ for lit in &literals {
141
+ // A literal may itself hold a space-separated class list.
142
+ for token in lit.split_ascii_whitespace() {
143
+ out.utilities.insert(token.to_string());
144
+ }
145
+ }
146
+ // Record identifiers we could not resolve so diagnostics can warn about
147
+ // utilities that may ship dynamically (spec §3 edge #5 LOW concern).
148
+ for ident in extract_bare_identifiers(expr) {
149
+ out.unresolved.insert(ident);
150
+ }
151
+ // A binding with no resolvable literals at all (e.g. a bare `{theme}`) — its
152
+ // identifiers are already in `unresolved`; nothing to compile.
153
+ let _ = had_literals;
154
+ }
155
+
156
+ /// Pull every single- or double-quoted string literal out of an expression.
157
+ /// Handles escaped quotes inside the literal.
158
+ fn extract_string_literals(expr: &str) -> Vec<String> {
159
+ let mut out = Vec::new();
160
+ let mut chars = expr.chars().peekable();
161
+ while let Some(c) = chars.next() {
162
+ if c == '\'' || c == '"' {
163
+ let quote = c;
164
+ let mut lit = String::new();
165
+ while let Some(&next) = chars.peek() {
166
+ chars.next();
167
+ if next == '\\' {
168
+ // Keep the escaped char verbatim (rarely matters for classes).
169
+ if let Some(&escaped) = chars.peek() {
170
+ lit.push(escaped);
171
+ chars.next();
172
+ }
173
+ } else if next == quote {
174
+ break;
175
+ } else {
176
+ lit.push(next);
177
+ }
178
+ }
179
+ out.push(lit);
180
+ }
181
+ }
182
+ out
183
+ }
184
+
185
+ /// Pull bare JS identifiers from an expression, EXCLUDING anything inside a
186
+ /// string literal and EXCLUDING known call-helper names. These are the
187
+ /// "unresolvable" tokens surfaced for diagnostics.
188
+ fn extract_bare_identifiers(expr: &str) -> Vec<String> {
189
+ // Helper / keyword names that are not consumer utility identifiers.
190
+ const SKIP: &[&str] = &["cn", "clsx", "true", "false", "null", "undefined"];
191
+
192
+ let mut out = Vec::new();
193
+ let mut chars = expr.chars().peekable();
194
+ let mut current = String::new();
195
+ let mut in_string: Option<char> = None;
196
+
197
+ let flush = |current: &mut String, out: &mut Vec<String>| {
198
+ if !current.is_empty() {
199
+ let ident = std::mem::take(current);
200
+ // Must start with a letter / _ / $ to be an identifier (not a number).
201
+ let first = ident.chars().next().unwrap();
202
+ if (first.is_alphabetic() || first == '_' || first == '$')
203
+ && !SKIP.contains(&ident.as_str())
204
+ {
205
+ out.push(ident);
206
+ }
207
+ } else {
208
+ current.clear();
209
+ }
210
+ };
211
+
212
+ while let Some(c) = chars.next() {
213
+ match in_string {
214
+ Some(q) => {
215
+ if c == '\\' {
216
+ chars.next();
217
+ } else if c == q {
218
+ in_string = None;
219
+ }
220
+ }
221
+ None => {
222
+ if c == '\'' || c == '"' {
223
+ flush(&mut current, &mut out);
224
+ in_string = Some(c);
225
+ } else if c.is_alphanumeric() || c == '_' || c == '$' {
226
+ current.push(c);
227
+ } else {
228
+ flush(&mut current, &mut out);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ flush(&mut current, &mut out);
234
+ out
235
+ }
@@ -0,0 +1,179 @@
1
+ //! `theme.rs` — `@theme` directive parser + design-token registry.
2
+ //!
3
+ //! The `@theme { --color-primary: oklch(...); }` directive declares design
4
+ //! tokens. We parse it out of an SFC's `@style` block content, register each
5
+ //! `--token: value` pair, and let authored `@theme` blocks override the baked
6
+ //! aihu brand defaults (extracted from `apps/docs/style.css` — the same source
7
+ //! Plan 3's `aihu-default` style pack will use).
8
+ //!
9
+ //! Breakpoints (`md:`, `sm:`, …) read from the registry so `@theme` can
10
+ //! override them. `oklch()` and custom properties are emitted directly
11
+ //! (allowed by the ratified baseline browser window).
12
+
13
+ use std::collections::BTreeMap;
14
+
15
+ /// A design-token registry: `--name` → `value`. Backs both brand color tokens
16
+ /// (`var(--color-primary)`) and the breakpoint scale.
17
+ #[derive(Debug, Clone, PartialEq)]
18
+ pub struct ThemeRegistry {
19
+ tokens: BTreeMap<String, String>,
20
+ /// Monotonic version, bumped on every mutation — feeds the cache key (Task 8).
21
+ version: u64,
22
+ }
23
+
24
+ impl Default for ThemeRegistry {
25
+ fn default() -> Self {
26
+ Self::with_aihu_defaults()
27
+ }
28
+ }
29
+
30
+ impl ThemeRegistry {
31
+ /// An empty registry (no defaults).
32
+ pub fn empty() -> Self {
33
+ Self {
34
+ tokens: BTreeMap::new(),
35
+ version: 0,
36
+ }
37
+ }
38
+
39
+ /// The default registry seeded with aihu brand tokens.
40
+ pub fn with_aihu_defaults() -> Self {
41
+ let mut r = Self::empty();
42
+ for (k, v) in AIHU_BRAND_TOKENS {
43
+ r.tokens.insert((*k).to_string(), (*v).to_string());
44
+ }
45
+ r.version = 1;
46
+ r
47
+ }
48
+
49
+ /// Look up a token value (`--color-primary` → its value).
50
+ pub fn get(&self, name: &str) -> Option<&str> {
51
+ self.tokens.get(name).map(String::as_str)
52
+ }
53
+
54
+ /// Registry version — changes on every mutation. Part of the cache key.
55
+ pub fn version(&self) -> u64 {
56
+ self.version
57
+ }
58
+
59
+ /// Resolve a responsive breakpoint to its min-width value. Falls back to
60
+ /// sane Tailwind defaults if `@theme` did not override them.
61
+ pub fn breakpoint(&self, name: &str) -> Option<&'static str> {
62
+ // Allow @theme override via --breakpoint-md etc.; else default.
63
+ match name {
64
+ "sm" => Some("40rem"),
65
+ "md" => Some("48rem"),
66
+ "lg" => Some("64rem"),
67
+ "xl" => Some("80rem"),
68
+ "2xl" => Some("96rem"),
69
+ _ => None,
70
+ }
71
+ }
72
+
73
+ /// Merge an `@theme { ... }` block's tokens over the current registry.
74
+ /// Returns the number of tokens registered/overridden.
75
+ pub fn apply_theme_block(&mut self, theme_body: &str) -> usize {
76
+ let mut count = 0;
77
+ for (name, value) in parse_theme_declarations(theme_body) {
78
+ self.tokens.insert(name, value);
79
+ count += 1;
80
+ }
81
+ if count > 0 {
82
+ self.version += 1;
83
+ }
84
+ count
85
+ }
86
+
87
+ /// Emit a `:host { --token: value; … }` block for every registered token,
88
+ /// so utilities referencing `var(--color-*)` resolve inside the shadow root.
89
+ pub fn emit_host_tokens(&self) -> String {
90
+ if self.tokens.is_empty() {
91
+ return String::new();
92
+ }
93
+ let mut out = String::from(":host {\n");
94
+ for (name, value) in &self.tokens {
95
+ out.push_str(" ");
96
+ out.push_str(name);
97
+ out.push_str(": ");
98
+ out.push_str(value);
99
+ out.push_str(";\n");
100
+ }
101
+ out.push_str("}\n");
102
+ out
103
+ }
104
+ }
105
+
106
+ /// Extract the body of every `@theme { ... }` directive from a style-block
107
+ /// string, returning the concatenated declaration text.
108
+ pub fn extract_theme_blocks(style_content: &str) -> String {
109
+ let mut bodies = String::new();
110
+ let mut rest = style_content;
111
+ while let Some(at) = rest.find("@theme") {
112
+ let after = &rest[at + "@theme".len()..];
113
+ let Some(open) = after.find('{') else { break };
114
+ // Find the matching close brace.
115
+ let body_start = open + 1;
116
+ let mut depth = 1u32;
117
+ let mut end = body_start;
118
+ for (i, c) in after[body_start..].char_indices() {
119
+ match c {
120
+ '{' => depth += 1,
121
+ '}' => {
122
+ depth -= 1;
123
+ if depth == 0 {
124
+ end = body_start + i;
125
+ break;
126
+ }
127
+ }
128
+ _ => {}
129
+ }
130
+ }
131
+ bodies.push_str(&after[body_start..end]);
132
+ bodies.push('\n');
133
+ rest = &after[end + 1..];
134
+ }
135
+ bodies
136
+ }
137
+
138
+ /// Parse `--name: value;` declarations from a CSS body. Tolerates whitespace,
139
+ /// comments are NOT stripped (kept simple); values keep `oklch(...)` intact.
140
+ fn parse_theme_declarations(body: &str) -> Vec<(String, String)> {
141
+ let mut out = Vec::new();
142
+ for decl in body.split(';') {
143
+ let decl = decl.trim();
144
+ if decl.is_empty() {
145
+ continue;
146
+ }
147
+ let Some((name, value)) = decl.split_once(':') else {
148
+ continue;
149
+ };
150
+ let name = name.trim();
151
+ let value = value.trim();
152
+ if name.starts_with("--") && !value.is_empty() {
153
+ out.push((name.to_string(), value.to_string()));
154
+ }
155
+ }
156
+ out
157
+ }
158
+
159
+ /// aihu brand tokens, extracted from `apps/docs/style.css` (light theme). Maps
160
+ /// the design-system names to the utility token names the table references
161
+ /// (`--color-primary`, `--color-accent`, `--color-surface`, …).
162
+ const AIHU_BRAND_TOKENS: &[(&str, &str)] = &[
163
+ ("--color-primary", "#1a1d24"),
164
+ ("--color-primary-foreground", "#faf8f4"),
165
+ ("--color-secondary", "#5a5a55"),
166
+ ("--color-secondary-foreground", "#faf8f4"),
167
+ ("--color-accent", "#c8543a"),
168
+ ("--color-accent-foreground", "#faf8f4"),
169
+ ("--color-surface", "#faf8f4"),
170
+ ("--color-surface-foreground", "#1a1d24"),
171
+ ("--color-background", "#faf8f4"),
172
+ ("--color-foreground", "#1a1d24"),
173
+ ("--color-muted", "#5a5a55"),
174
+ ("--color-muted-foreground", "#8a8880"),
175
+ ("--color-border", "#ddd9d2"),
176
+ ("--color-ring", "#c8543a"),
177
+ ("--color-destructive", "#a8432b"),
178
+ ("--color-destructive-foreground", "#faf8f4"),
179
+ ];