@praxisjs/css 0.3.4 → 0.3.5

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 (146) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/builder/css-builder.d.ts.map +1 -1
  3. package/dist/builder/css-builder.js +68 -74
  4. package/dist/builder/css-builder.js.map +1 -1
  5. package/dist/builder/keyframes.d.ts +5 -0
  6. package/dist/builder/keyframes.d.ts.map +1 -1
  7. package/dist/builder/keyframes.js +12 -14
  8. package/dist/builder/keyframes.js.map +1 -1
  9. package/dist/cx.d.ts.map +1 -1
  10. package/dist/cx.js +23 -18
  11. package/dist/cx.js.map +1 -1
  12. package/dist/decorators/param.d.ts +1 -2
  13. package/dist/decorators/param.d.ts.map +1 -1
  14. package/dist/decorators/param.js +9 -17
  15. package/dist/decorators/param.js.map +1 -1
  16. package/dist/decorators/style.d.ts.map +1 -1
  17. package/dist/decorators/style.js +61 -33
  18. package/dist/decorators/style.js.map +1 -1
  19. package/dist/decorators/styled.d.ts.map +1 -1
  20. package/dist/decorators/styled.js +112 -95
  21. package/dist/decorators/styled.js.map +1 -1
  22. package/dist/extract.d.ts.map +1 -1
  23. package/dist/extract.js +19 -35
  24. package/dist/extract.js.map +1 -1
  25. package/dist/global.d.ts +3 -0
  26. package/dist/global.d.ts.map +1 -1
  27. package/dist/global.js +9 -4
  28. package/dist/global.js.map +1 -1
  29. package/dist/internal/kebab.d.ts +5 -0
  30. package/dist/internal/kebab.d.ts.map +1 -0
  31. package/dist/internal/kebab.js +23 -0
  32. package/dist/internal/kebab.js.map +1 -0
  33. package/dist/internal/lifecycle.d.ts +15 -0
  34. package/dist/internal/lifecycle.d.ts.map +1 -0
  35. package/dist/internal/lifecycle.js +16 -0
  36. package/dist/internal/lifecycle.js.map +1 -0
  37. package/dist/internal/registry.d.ts +5 -1
  38. package/dist/internal/registry.d.ts.map +1 -1
  39. package/dist/internal/registry.js +9 -23
  40. package/dist/internal/registry.js.map +1 -1
  41. package/dist/internal/sheet.d.ts +20 -0
  42. package/dist/internal/sheet.d.ts.map +1 -0
  43. package/dist/internal/sheet.js +56 -0
  44. package/dist/internal/sheet.js.map +1 -0
  45. package/dist/internal/static-mode.d.ts +2 -0
  46. package/dist/internal/static-mode.d.ts.map +1 -0
  47. package/dist/internal/static-mode.js +2 -0
  48. package/dist/internal/static-mode.js.map +1 -0
  49. package/dist/preflight.d.ts +3 -1
  50. package/dist/preflight.d.ts.map +1 -1
  51. package/dist/preflight.js +7 -3
  52. package/dist/preflight.js.map +1 -1
  53. package/dist/tokens/decorators.d.ts.map +1 -1
  54. package/dist/tokens/decorators.js +13 -4
  55. package/dist/tokens/decorators.js.map +1 -1
  56. package/dist/tokens/theme-instance.d.ts +5 -0
  57. package/dist/tokens/theme-instance.d.ts.map +1 -1
  58. package/dist/tokens/theme-instance.js +25 -17
  59. package/dist/tokens/theme-instance.js.map +1 -1
  60. package/dist/tokens/token-sheet.d.ts.map +1 -1
  61. package/dist/tokens/token-sheet.js +12 -9
  62. package/dist/tokens/token-sheet.js.map +1 -1
  63. package/package.json +13 -5
  64. package/src/builder/css-builder.ts +79 -83
  65. package/src/builder/keyframes.ts +12 -20
  66. package/src/cx.ts +20 -13
  67. package/src/decorators/param.ts +12 -16
  68. package/src/decorators/style.ts +79 -35
  69. package/src/decorators/styled.ts +127 -111
  70. package/src/extract.ts +18 -34
  71. package/src/global.ts +10 -9
  72. package/src/internal/kebab.ts +23 -0
  73. package/src/internal/lifecycle.ts +21 -0
  74. package/src/internal/registry.ts +9 -35
  75. package/src/internal/sheet.ts +71 -0
  76. package/src/internal/static-mode.ts +6 -0
  77. package/src/preflight.ts +8 -3
  78. package/src/tokens/decorators.ts +15 -3
  79. package/src/tokens/theme-instance.ts +26 -16
  80. package/src/tokens/token-sheet.ts +12 -12
  81. package/dist/__tests__/css-builder.test.d.ts +0 -2
  82. package/dist/__tests__/css-builder.test.d.ts.map +0 -1
  83. package/dist/__tests__/css-builder.test.js +0 -198
  84. package/dist/__tests__/css-builder.test.js.map +0 -1
  85. package/dist/__tests__/cx.test.d.ts +0 -2
  86. package/dist/__tests__/cx.test.d.ts.map +0 -1
  87. package/dist/__tests__/cx.test.js +0 -47
  88. package/dist/__tests__/cx.test.js.map +0 -1
  89. package/dist/__tests__/decorators.test.d.ts +0 -2
  90. package/dist/__tests__/decorators.test.d.ts.map +0 -1
  91. package/dist/__tests__/decorators.test.js +0 -264
  92. package/dist/__tests__/decorators.test.js.map +0 -1
  93. package/dist/__tests__/extract.test.d.ts +0 -2
  94. package/dist/__tests__/extract.test.d.ts.map +0 -1
  95. package/dist/__tests__/extract.test.js +0 -416
  96. package/dist/__tests__/extract.test.js.map +0 -1
  97. package/dist/__tests__/global.test.d.ts +0 -2
  98. package/dist/__tests__/global.test.d.ts.map +0 -1
  99. package/dist/__tests__/global.test.js +0 -136
  100. package/dist/__tests__/global.test.js.map +0 -1
  101. package/dist/__tests__/keyframes.test.d.ts +0 -2
  102. package/dist/__tests__/keyframes.test.d.ts.map +0 -1
  103. package/dist/__tests__/keyframes.test.js +0 -90
  104. package/dist/__tests__/keyframes.test.js.map +0 -1
  105. package/dist/__tests__/preflight.test.d.ts +0 -2
  106. package/dist/__tests__/preflight.test.d.ts.map +0 -1
  107. package/dist/__tests__/preflight.test.js +0 -74
  108. package/dist/__tests__/preflight.test.js.map +0 -1
  109. package/dist/__tests__/registry.test.d.ts +0 -2
  110. package/dist/__tests__/registry.test.d.ts.map +0 -1
  111. package/dist/__tests__/registry.test.js +0 -132
  112. package/dist/__tests__/registry.test.js.map +0 -1
  113. package/dist/__tests__/style.test.d.ts +0 -2
  114. package/dist/__tests__/style.test.d.ts.map +0 -1
  115. package/dist/__tests__/style.test.js +0 -281
  116. package/dist/__tests__/style.test.js.map +0 -1
  117. package/dist/__tests__/styled.test.d.ts +0 -2
  118. package/dist/__tests__/styled.test.d.ts.map +0 -1
  119. package/dist/__tests__/styled.test.js +0 -904
  120. package/dist/__tests__/styled.test.js.map +0 -1
  121. package/dist/__tests__/theme-instance.test.d.ts +0 -2
  122. package/dist/__tests__/theme-instance.test.d.ts.map +0 -1
  123. package/dist/__tests__/theme-instance.test.js +0 -309
  124. package/dist/__tests__/theme-instance.test.js.map +0 -1
  125. package/dist/__tests__/theme-uninit.test.d.ts +0 -2
  126. package/dist/__tests__/theme-uninit.test.d.ts.map +0 -1
  127. package/dist/__tests__/theme-uninit.test.js +0 -10
  128. package/dist/__tests__/theme-uninit.test.js.map +0 -1
  129. package/dist/__tests__/token-sheet.test.d.ts +0 -2
  130. package/dist/__tests__/token-sheet.test.d.ts.map +0 -1
  131. package/dist/__tests__/token-sheet.test.js +0 -74
  132. package/dist/__tests__/token-sheet.test.js.map +0 -1
  133. package/src/__tests__/css-builder.test.ts +0 -230
  134. package/src/__tests__/cx.test.ts +0 -63
  135. package/src/__tests__/decorators.test.ts +0 -127
  136. package/src/__tests__/extract.test.ts +0 -459
  137. package/src/__tests__/global.test.ts +0 -163
  138. package/src/__tests__/keyframes.test.ts +0 -103
  139. package/src/__tests__/preflight.test.ts +0 -85
  140. package/src/__tests__/registry.test.ts +0 -151
  141. package/src/__tests__/style.test.ts +0 -170
  142. package/src/__tests__/styled.test.ts +0 -443
  143. package/src/__tests__/theme-instance.test.ts +0 -374
  144. package/src/__tests__/theme-uninit.test.ts +0 -12
  145. package/src/__tests__/token-sheet.test.ts +0 -87
  146. package/tsconfig.json +0 -8
package/src/cx.ts CHANGED
@@ -8,19 +8,26 @@ export type ClassValue =
8
8
  | Record<string, unknown>;
9
9
 
10
10
  export function cx(...args: ClassValue[]): string {
11
- const classes: string[] = [];
12
- for (const arg of args) {
13
- if (!arg && arg !== 0) continue;
14
- if (typeof arg === "string" || typeof arg === "number") {
15
- classes.push(String(arg));
16
- } else if (Array.isArray(arg)) {
17
- const inner = cx(...(arg as ClassValue[]));
18
- if (inner) classes.push(inner);
19
- } else if (typeof arg === "object") {
20
- for (const [key, val] of Object.entries(arg)) {
21
- if (val) classes.push(key);
22
- }
11
+ let out = "";
12
+ for (const arg of args) out = append(out, arg);
13
+ return out;
14
+ }
15
+
16
+ // Builds the string directly instead of collecting parts in an array: cx() runs on every render of
17
+ // every styled component.
18
+ function append(out: string, arg: ClassValue): string {
19
+ if (!arg && arg !== 0) return out;
20
+ if (typeof arg === "string" || typeof arg === "number") {
21
+ return out === "" ? String(arg) : `${out} ${String(arg)}`;
22
+ }
23
+ if (Array.isArray(arg)) {
24
+ for (const item of arg) out = append(out, item);
25
+ return out;
26
+ }
27
+ if (typeof arg === "object") {
28
+ for (const key in arg) {
29
+ if (Object.hasOwn(arg, key) && arg[key]) out = out === "" ? key : `${out} ${key}`;
23
30
  }
24
31
  }
25
- return classes.join(" ");
32
+ return out;
26
33
  }
@@ -1,13 +1,7 @@
1
- import { createFieldDecorator } from "@praxisjs/decorators";
1
+ import { addParamName } from "../internal/sheet.js";
2
2
 
3
3
  import type { ReactiveStylesheet } from "../base/stylesheet.js";
4
4
 
5
- const PARAM_META = new WeakMap<object, Set<string>>();
6
-
7
- export function getParamNames(ctor: object): Set<string> {
8
- return PARAM_META.get(ctor) ?? new Set();
9
- }
10
-
11
5
  /**
12
6
  * Marks a field in a {@link ReactiveStylesheet} subclass as a reactive CSS custom property.
13
7
  * The field name becomes `--fieldName` on the component's container element.
@@ -28,13 +22,15 @@ export function getParamNames(ctor: object): Set<string> {
28
22
  * this.$card.color = '#ef4444' // updates --color reactively
29
23
  */
30
24
  export function Param() {
31
- return createFieldDecorator<ReactiveStylesheet>({
32
- bind(instance, name) {
33
- const ctor = (instance as object).constructor as object;
34
- let meta = PARAM_META.get(ctor);
35
- if (!meta) { meta = new Set(); PARAM_META.set(ctor, meta); }
36
- meta.add(name);
37
- return {};
38
- },
39
- });
25
+ // Only records the field name: a stylesheet is instantiated once per class, as the template
26
+ // `@Styled` compiles, and every component instance then gets its own signals from there.
27
+ return function <This extends ReactiveStylesheet, Value>(
28
+ _value: undefined,
29
+ context: ClassFieldDecoratorContext<This, Value>,
30
+ ): void {
31
+ const name = context.name as string;
32
+ context.addInitializer(function (this: This) {
33
+ addParamName(this.constructor, name);
34
+ });
35
+ };
40
36
  }
@@ -1,5 +1,26 @@
1
- import { effect, getComponentAnchor, signal } from "@praxisjs/core/internal";
2
- import { createFieldDecorator } from "@praxisjs/decorators";
1
+ import {
2
+ createEffect,
3
+ disposeEffect,
4
+ getComponentAnchor,
5
+ readSignal,
6
+ SignalNode,
7
+ writeSignal,
8
+ type EffectNode,
9
+ } from "@praxisjs/core/internal";
10
+ import { addClaimableInitializer, assertFieldContext } from "@praxisjs/decorators";
11
+
12
+ import { HookedField, type LifecycleHost } from "../internal/lifecycle.js";
13
+
14
+ class StyleField extends HookedField {
15
+ readonly node: SignalNode<string | number>;
16
+ effect: EffectNode | undefined = undefined;
17
+ el: HTMLElement | undefined = undefined;
18
+
19
+ constructor(instance: LifecycleHost, value: string | number) {
20
+ super(instance);
21
+ this.node = new SignalNode(value);
22
+ }
23
+ }
3
24
 
4
25
  /**
5
26
  * Reactive field decorator that binds a signal value to a CSS custom property
@@ -15,37 +36,60 @@ import { createFieldDecorator } from "@praxisjs/decorators";
15
36
  * }
16
37
  */
17
38
  export function Style(property: `--${string}`) {
18
- return createFieldDecorator<object>({
19
- bind(instance, _name, initialValue) {
20
- const sig = signal(initialValue as string | number);
21
- let stopEffect: (() => void) | null = null;
22
-
23
- return {
24
- descriptor: {
25
- get() {
26
- return sig();
27
- },
28
- set(v: string | number) {
29
- sig.set(v);
30
- },
31
- },
32
-
33
- onMount() {
34
- const el = getComponentAnchor(instance)?.parentElement ?? null;
35
- if (!el) return;
36
-
37
- stopEffect = effect(() => {
38
- el.style.setProperty(property, String(sig()));
39
- });
40
- },
41
-
42
- onUnmount() {
43
- stopEffect?.();
44
- stopEffect = null;
45
- const el = getComponentAnchor(instance)?.parentElement ?? null;
46
- if (el) el.style.removeProperty(property);
47
- },
48
- };
49
- },
50
- });
39
+ return function <This extends object, Value = unknown>(
40
+ _value: undefined,
41
+ context: ClassFieldDecoratorContext<This, Value>,
42
+ ): void {
43
+ assertFieldContext(context);
44
+ const name = context.name;
45
+ // Accessors and hooks are shared by every instance of the decorated field; the instance's
46
+ // own state lives in this slot.
47
+ const slot = Symbol(`praxis.style.${String(name)}`);
48
+ type Host = LifecycleHost & Record<typeof slot, StyleField>;
49
+
50
+ const descriptor: PropertyDescriptor = {
51
+ enumerable: true,
52
+ configurable: true,
53
+ get(this: Host) {
54
+ return readSignal(this[slot].node);
55
+ },
56
+ set(this: Host, value: string | number) {
57
+ writeSignal(this[slot].node, value);
58
+ },
59
+ };
60
+
61
+ function onMount(this: Host): void {
62
+ const field = this[slot];
63
+ field.prevMount?.call(this);
64
+ const el = getComponentAnchor(this)?.parentElement;
65
+ if (!el) return;
66
+ const node = field.node;
67
+ field.el = el;
68
+ field.effect = createEffect(() => {
69
+ el.style.setProperty(property, String(readSignal(node)));
70
+ });
71
+ }
72
+
73
+ function onUnmount(this: Host): void {
74
+ const field = this[slot];
75
+ field.prevUnmount?.call(this);
76
+ if (field.effect) {
77
+ disposeEffect(field.effect);
78
+ field.effect = undefined;
79
+ }
80
+ // The element it was set on, even if the anchor has been detached by now.
81
+ if (field.el) {
82
+ field.el.style.removeProperty(property);
83
+ field.el = undefined;
84
+ }
85
+ }
86
+
87
+ addClaimableInitializer(context, function (value) {
88
+ const host = this as unknown as Host & Record<PropertyKey, unknown>;
89
+ host[slot] = new StyleField(host, value as string | number);
90
+ Object.defineProperty(host, name, descriptor);
91
+ host.onMount = onMount;
92
+ host.onUnmount = onUnmount;
93
+ });
94
+ };
51
95
  }
@@ -1,63 +1,72 @@
1
1
  import type { StatefulComponent } from "@praxisjs/core";
2
- import { effect, getComponentAnchor, signal } from "@praxisjs/core/internal";
3
- import { createFieldDecorator } from "@praxisjs/decorators";
4
-
5
- import { getParamNames } from "./param.js";
6
- import { isCSSBuilder } from "../builder/css-builder.js";
7
- import { hashCSS } from "../internal/hash.js";
8
- import { injectStyle, releaseStyle } from "../internal/registry.js";
2
+ import {
3
+ createEffect,
4
+ disposeEffect,
5
+ getComponentAnchor,
6
+ readSignal,
7
+ SignalNode,
8
+ writeSignal,
9
+ type EffectNode,
10
+ } from "@praxisjs/core/internal";
11
+ import { assertFieldContext } from "@praxisjs/decorators";
12
+
13
+ import { HookedField, type LifecycleHost } from "../internal/lifecycle.js";
14
+ import { injectStyle } from "../internal/registry.js";
15
+ import { compileSheet, type CompiledSheet } from "../internal/sheet.js";
9
16
 
10
17
  import type { ReactiveStylesheet, Stylesheet } from "../base/stylesheet.js";
11
18
 
12
- // ─── Class data cache ─────────────────────────────────────────────────────────
19
+ type ParamValue = string | number;
20
+
21
+ const PARAMS = Symbol("praxis.styled.params");
13
22
 
14
- interface StyleClassData {
15
- classMap: Readonly<Record<string, string>>;
16
- rawCSS: string;
17
- registryKey: string;
18
- paramDefaults: Readonly<Record<string, string | number>>;
23
+ interface ParamSheet {
24
+ readonly [PARAMS]: Array<SignalNode<ParamValue>>;
19
25
  }
20
26
 
21
- const styleClassCache = new Map<new () => Stylesheet, StyleClassData>();
22
-
23
- function getStyleClassData(StyleClass: new () => Stylesheet): StyleClassData {
24
- const cached = styleClassCache.get(StyleClass);
25
- if (cached) return cached;
26
-
27
- // Constructing the template fires @Param() addInitializers, populating PARAM_META.
28
- const template = new StyleClass() as unknown as Record<string, unknown>;
29
- const paramNames = getParamNames(StyleClass);
30
-
31
- const classMap: Record<string, string> = {};
32
- const blocks: string[] = [];
33
- const paramDefaults: Record<string, string | number> = {};
34
-
35
- for (const [key, value] of Object.entries(template)) {
36
- if (key.startsWith("$")) {
37
- // Accept plain strings or fluent CSSBuilder values.
38
- const css = typeof value === "string" ? value : isCSSBuilder(value) ? String(value) : null;
39
- if (css !== null) {
40
- const safeName = key.slice(1).replace(/[^\w-]/g, "-");
41
- const className = `prx-${safeName}-${hashCSS(css)}`;
42
- classMap[key] = className;
43
- blocks.push(`.${className} { ${css.trim()} }`);
44
- }
45
- } else if (paramNames.has(key) && (typeof value === "string" || typeof value === "number")) {
46
- paramDefaults[key] = value;
27
+ // One class per stylesheet: the class names and the @Param() accessors live on its prototype,
28
+ // so an instance is a single slot holding its signal nodes.
29
+ function createParamSheetClass(sheet: CompiledSheet): new (nodes: Array<SignalNode<ParamValue>>) => ParamSheet {
30
+ class Sheet implements ParamSheet {
31
+ readonly [PARAMS]: Array<SignalNode<ParamValue>>;
32
+
33
+ constructor(nodes: Array<SignalNode<ParamValue>>) {
34
+ this[PARAMS] = nodes;
47
35
  }
48
36
  }
49
-
50
- const data: StyleClassData = {
51
- classMap: Object.freeze(classMap),
52
- rawCSS: blocks.join("\n"),
53
- registryKey: blocks.length ? hashCSS(blocks.join("\n")) : "",
54
- paramDefaults: Object.freeze(paramDefaults),
55
- };
56
- styleClassCache.set(StyleClass, data);
57
- return data;
37
+ const proto = Sheet.prototype;
38
+ for (const key in sheet.classMap) {
39
+ Object.defineProperty(proto, key, { value: sheet.classMap[key], writable: true, enumerable: true, configurable: true });
40
+ }
41
+ sheet.params.forEach((param, i) => {
42
+ Object.defineProperty(proto, param, {
43
+ enumerable: true,
44
+ configurable: true,
45
+ get(this: ParamSheet) {
46
+ return readSignal(this[PARAMS][i]);
47
+ },
48
+ set(this: ParamSheet, value: ParamValue) {
49
+ writeSignal(this[PARAMS][i], value);
50
+ },
51
+ });
52
+ });
53
+ return Sheet;
58
54
  }
59
55
 
60
- // ─── @Styled field decorator ──────────────────────────────────────────────────
56
+ class ParamField extends HookedField {
57
+ readonly nodes: Array<SignalNode<ParamValue>>;
58
+ readonly sheet: ParamSheet;
59
+ effects: EffectNode[] | undefined = undefined;
60
+ el: HTMLElement | undefined = undefined;
61
+
62
+ constructor(instance: LifecycleHost, Sheet: new (nodes: Array<SignalNode<ParamValue>>) => ParamSheet, defaults: readonly ParamValue[]) {
63
+ super(instance);
64
+ const nodes = new Array<SignalNode<ParamValue>>(defaults.length);
65
+ for (let i = 0; i < defaults.length; i++) nodes[i] = new SignalNode(defaults[i]);
66
+ this.nodes = nodes;
67
+ this.sheet = new Sheet(nodes);
68
+ }
69
+ }
61
70
 
62
71
  /**
63
72
  * Scoped CSS field decorator. Receives a {@link Stylesheet} or
@@ -97,71 +106,78 @@ export function Styled<T extends Stylesheet>(
97
106
  ): [T] extends [ReactiveStylesheet]
98
107
  ? (value: undefined, context: ClassFieldDecoratorContext<StatefulComponent, T>) => void
99
108
  : (value: undefined, context: ClassFieldDecoratorContext<object, T>) => void {
100
- const data = getStyleClassData(StyleClass);
109
+ const sheet = compileSheet(StyleClass);
110
+ const { css, key } = sheet;
101
111
 
102
112
  // Eagerly inject CSS at decoration time (module load) so it is in the DOM
103
113
  // before any component mounts — avoids layout-measurement timing issues.
104
- // The per-instance onMount/onUnmount still manage the ref count on top of
105
- // this base reference, which intentionally stays for the page lifetime.
106
- if (data.rawCSS) injectStyle(data.registryKey, data.rawCSS);
107
-
108
- return createFieldDecorator<object>({
109
- bind(instance) {
110
- type ParamSig = ReturnType<typeof signal<string | number>>;
111
- const paramSignals: Record<string, ParamSig> = {};
112
- const hasParams = Object.keys(data.paramDefaults).length > 0;
113
-
114
- const stylesheetObj: Record<string, unknown> = hasParams
115
- ? { ...data.classMap }
116
- : (data.classMap as Record<string, unknown>);
117
-
118
- for (const [paramName, defaultVal] of Object.entries(data.paramDefaults)) {
119
- const sig = signal(defaultVal);
120
- paramSignals[paramName] = sig;
121
- Object.defineProperty(stylesheetObj, paramName, {
122
- get() { return sig(); },
123
- set(v: string | number) { sig.set(v); },
124
- enumerable: true,
125
- configurable: true,
126
- });
114
+ if (css) injectStyle(key, css);
115
+
116
+ // Both paths return the field's value from its initializer, which is much cheaper than redefining
117
+ // the field with Object.defineProperty on every new instance. Each instance also re-checks the
118
+ // stylesheet (cheap when present): it is collected again during a server render, and put back
119
+ // if its <style> was removed.
120
+ if (sheet.params.length === 0) {
121
+ // The same frozen class map for every instance: no per-instance object, signals or hooks.
122
+ const { classMap } = sheet;
123
+ return ((_value: undefined, context: ClassFieldDecoratorContext) => {
124
+ assertFieldContext(context);
125
+ return () => {
126
+ if (css) injectStyle(key, css);
127
+ return classMap;
128
+ };
129
+ }) as never;
130
+ }
131
+
132
+ const Sheet = createParamSheetClass(sheet);
133
+ const { defaults } = sheet;
134
+ const vars = sheet.params.map((param) => `--${param}`);
135
+
136
+ return ((_value: undefined, context: ClassFieldDecoratorContext) => {
137
+ assertFieldContext(context);
138
+ const name = context.name;
139
+ const slot = Symbol(`praxis.styled.${String(name)}`);
140
+ type Host = LifecycleHost & Record<typeof slot, ParamField>;
141
+
142
+ function onMount(this: Host): void {
143
+ const field = this[slot];
144
+ field.prevMount?.call(this);
145
+ const el = getComponentAnchor(this)?.parentElement;
146
+ if (!el) return;
147
+ const effects: EffectNode[] = [];
148
+ for (let i = 0; i < vars.length; i++) {
149
+ const node = field.nodes[i];
150
+ const prop = vars[i];
151
+ effects.push(createEffect(() => {
152
+ el.style.setProperty(prop, String(readSignal(node)));
153
+ }));
127
154
  }
155
+ field.el = el;
156
+ field.effects = effects;
157
+ }
128
158
 
129
- let stopFns: Array<() => void> = [];
130
-
131
- return {
132
- descriptor: { value: stylesheetObj, writable: false },
133
-
134
- onMount() {
135
- // Increment ref-count for this instance (base ref injected at decoration time).
136
- if (data.rawCSS) injectStyle(data.registryKey, data.rawCSS);
137
-
138
- if (hasParams) {
139
- const el = getComponentAnchor(instance)?.parentElement;
140
- if (el) {
141
- for (const [paramName, sig] of Object.entries(paramSignals)) {
142
- stopFns.push(effect(() => {
143
- el.style.setProperty(`--${paramName}`, String(sig()));
144
- }));
145
- }
146
- }
147
- }
148
- },
149
-
150
- onUnmount() {
151
- if (data.rawCSS) releaseStyle(data.registryKey);
152
- for (const stop of stopFns) stop();
153
- stopFns = [];
154
-
155
- if (hasParams) {
156
- const el = getComponentAnchor(instance)?.parentElement;
157
- if (el) {
158
- for (const paramName of Object.keys(paramSignals)) {
159
- el.style.removeProperty(`--${paramName}`);
160
- }
161
- }
162
- }
163
- },
164
- };
165
- },
166
- });
159
+ function onUnmount(this: Host): void {
160
+ const field = this[slot];
161
+ field.prevUnmount?.call(this);
162
+ const { effects, el } = field;
163
+ if (effects) {
164
+ for (const effect of effects) disposeEffect(effect);
165
+ field.effects = undefined;
166
+ }
167
+ // The element the vars were set on, even if the anchor has been detached by now.
168
+ if (el) {
169
+ for (const prop of vars) el.style.removeProperty(prop);
170
+ field.el = undefined;
171
+ }
172
+ }
173
+
174
+ return function (this: Host): ParamSheet {
175
+ if (css) injectStyle(key, css);
176
+ const field = new ParamField(this, Sheet, defaults);
177
+ this[slot] = field;
178
+ this.onMount = onMount;
179
+ this.onUnmount = onUnmount;
180
+ return field.sheet;
181
+ };
182
+ }) as never;
167
183
  }
package/src/extract.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { Stylesheet, ReactiveStylesheet } from "./base/stylesheet.js";
2
- import { isCSSBuilder, createCSSBuilder, type CSSProperties } from "./builder/css-builder.js";
2
+ import { createCSSBuilder, type CSSProperties } from "./builder/css-builder.js";
3
+ import { keyframesRule } from "./builder/keyframes.js";
4
+ import { globalCSS, type GlobalStyleFactory, type GlobalStyleOptions } from "./global.js";
3
5
  import { hashCSS } from "./internal/hash.js";
6
+ import { compileSheet } from "./internal/sheet.js";
7
+ import { preflightCSS, type PreflightOptions } from "./preflight.js";
8
+ import { instanceToVars, varsToCSS } from "./tokens/theme-instance.js";
4
9
  import { TokenSheet, tokenVars } from "./tokens/token-sheet.js";
5
10
 
6
11
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -12,18 +17,10 @@ type Ctor = new (...args: any[]) => unknown;
12
17
 
13
18
  // ─── Helpers ──────────────────────────────────────────────────────────────────
14
19
 
15
- function camelToKebab(s: string): string {
16
- return s.replace(/([A-Z])/g, "-$1").toLowerCase();
17
- }
18
-
19
20
  function themeToRootCSS(ThemeClass: Ctor): string | null {
20
21
  try {
21
- const inst = new ThemeClass() as Record<string, unknown>;
22
- const vars = Object.entries(inst)
23
- .filter(([, v]) => typeof v === "string" || typeof v === "number")
24
- .map(([k, v]) => ` --${camelToKebab(k)}: ${String(v)};`)
25
- .join("\n");
26
- return vars ? `:root {\n${vars}\n}` : null;
22
+ const vars = instanceToVars(new ThemeClass() as Record<string, unknown>);
23
+ return Object.keys(vars).length > 0 ? varsToCSS(vars) : null;
27
24
  } catch {
28
25
  return null;
29
26
  }
@@ -31,16 +28,8 @@ function themeToRootCSS(ThemeClass: Ctor): string | null {
31
28
 
32
29
  function collectSheetCSS(SheetClass: Ctor, emit: EmitFn): void {
33
30
  try {
34
- const inst = new SheetClass() as Record<string, unknown>;
35
- const blocks: string[] = [];
36
- for (const [k, v] of Object.entries(inst)) {
37
- if (!k.startsWith("$")) continue;
38
- const css = typeof v === "string" ? v : (isCSSBuilder(v) ? String(v) : null);
39
- if (!css) continue;
40
- const name = k.slice(1).replace(/[^\w-]/g, "-");
41
- blocks.push(`.prx-${name}-${hashCSS(css)} { ${css.trim()} }`);
42
- }
43
- if (blocks.length) emit(blocks.join("\n"));
31
+ const { css } = compileSheet(SheetClass);
32
+ if (css) emit(css);
44
33
  } catch { /* skip sheets that can't be instantiated at build time */ }
45
34
  }
46
35
 
@@ -117,20 +106,15 @@ export function extractionModule(emit: EmitFn): Record<string, unknown> {
117
106
  Style: () => () => {},
118
107
  cx: () => "",
119
108
  keyframes: (name: string, stops: Record<string, CSSProperties>) => {
120
- const stopToCSS = (p: CSSProperties) =>
121
- Object.entries(p)
122
- .filter(([, v]) => v != null)
123
- .map(([k, v]) => ` ${camelToKebab(k)}: ${String(v)};`)
124
- .join("\n");
125
- const body = Object.entries(stops)
126
- .map(([s, p]) => ` ${s} {\n${stopToCSS(p)}\n }`)
127
- .join("\n");
128
- const sn = `prx-${name}-${hashCSS(body)}`;
129
- dedupEmit(`@keyframes ${sn} {\n${body}\n}`);
130
- return sn;
109
+ const rule = keyframesRule(name, stops);
110
+ dedupEmit(rule.css);
111
+ return rule.name;
112
+ },
113
+ globalStyle: (factory: GlobalStyleFactory, options?: GlobalStyleOptions) => {
114
+ dedupEmit(globalCSS(factory, options));
131
115
  },
132
- globalStyle: (factory: (css: typeof createCSSBuilder) => unknown) => {
133
- dedupEmit(String(factory(createCSSBuilder)));
116
+ preflight: (options?: PreflightOptions) => {
117
+ dedupEmit(preflightCSS(options));
134
118
  },
135
119
  };
136
120
  }
package/src/global.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createCSSBuilder, type CSSProperties, type CSSBuilder } from "./builder/css-builder.js";
2
2
  import { getCollector } from "./internal/collector.js";
3
3
  import { hashCSS } from "./internal/hash.js";
4
+ import { STATIC_MODE } from "./internal/static-mode.js";
4
5
 
5
6
  // ─── Types ────────────────────────────────────────────────────────────────────
6
7
 
@@ -17,14 +18,14 @@ export interface GlobalStyleOptions {
17
18
  layer?: string | false;
18
19
  }
19
20
 
20
- // ─── Static mode ──────────────────────────────────────────────────────────────
21
-
22
- declare const __PRAXIS_CSS_STATIC__: boolean | undefined;
23
- const STATIC_MODE: boolean =
24
- typeof __PRAXIS_CSS_STATIC__ !== "undefined" && __PRAXIS_CSS_STATIC__;
25
-
26
21
  const injected = new Set<string>();
27
22
 
23
+ /** The CSS a `globalStyle()` call injects — shared with build-time extraction. */
24
+ export function globalCSS(factory: GlobalStyleFactory, { layer }: GlobalStyleOptions = {}): string {
25
+ const rawCSS = String(factory(createCSSBuilder));
26
+ return layer ? `@layer ${layer} {\n${rawCSS}\n}` : rawCSS;
27
+ }
28
+
28
29
  // ─── globalStyle ─────────────────────────────────────────────────────────────
29
30
 
30
31
  /**
@@ -54,10 +55,10 @@ const injected = new Set<string>();
54
55
  * globalStyle(_css => `body { font-family: system-ui; }`, { layer: 'base' })
55
56
  */
56
57
  export function globalStyle(factory: GlobalStyleFactory, options: GlobalStyleOptions = {}): void {
57
- const rawCSS = String(factory(createCSSBuilder));
58
- const { layer } = options;
59
- const css = layer ? `@layer ${layer} {\n${rawCSS}\n}` : rawCSS;
58
+ injectGlobalCSS(globalCSS(factory, options));
59
+ }
60
60
 
61
+ export function injectGlobalCSS(css: string): void {
61
62
  const key = hashCSS(css);
62
63
  if (injected.has(key)) return;
63
64
  injected.add(key);
@@ -0,0 +1,23 @@
1
+ const cache = new Map<string, string>();
2
+
3
+ /** `backgroundColor` → `background-color`. Memoized: the same few hundred property and token names repeat. */
4
+ export function toKebab(camel: string): string {
5
+ let kebab = cache.get(camel);
6
+ if (kebab === undefined) {
7
+ kebab = camel.replace(/([A-Z])/g, "-$1").toLowerCase();
8
+ cache.set(camel, kebab);
9
+ }
10
+ return kebab;
11
+ }
12
+
13
+ /** One `prop: value;` line per defined entry, each prefixed with `indent`. */
14
+ export function declarations(props: Readonly<Record<string, string | number | null | undefined>>, indent: string): string {
15
+ let out = "";
16
+ for (const key in props) {
17
+ const value = props[key];
18
+ if (value == null) continue;
19
+ const line = `${indent}${toKebab(key)}: ${String(value)};`;
20
+ out = out === "" ? line : `${out}\n${line}`;
21
+ }
22
+ return out;
23
+ }
@@ -0,0 +1,21 @@
1
+ export interface LifecycleHost {
2
+ onMount?(): void;
3
+ onUnmount?(): void;
4
+ }
5
+
6
+ /**
7
+ * Per-instance state of a decorated field that needs mount/unmount hooks. The hooks themselves
8
+ * are shared per decorated field and find this state through a symbol slot, so chaining onto
9
+ * the instance's own `onMount`/`onUnmount` allocates no closures.
10
+ */
11
+ export class HookedField {
12
+ readonly prevMount: (() => void) | undefined;
13
+ readonly prevUnmount: (() => void) | undefined;
14
+
15
+ constructor(instance: LifecycleHost) {
16
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- called back with `.call(instance)`
17
+ this.prevMount = instance.onMount;
18
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- called back with `.call(instance)`
19
+ this.prevUnmount = instance.onUnmount;
20
+ }
21
+ }