@praxisjs/css 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 (164) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/dist/__tests__/css-builder.test.d.ts +2 -0
  4. package/dist/__tests__/css-builder.test.d.ts.map +1 -0
  5. package/dist/__tests__/css-builder.test.js +198 -0
  6. package/dist/__tests__/css-builder.test.js.map +1 -0
  7. package/dist/__tests__/cx.test.d.ts +2 -0
  8. package/dist/__tests__/cx.test.d.ts.map +1 -0
  9. package/dist/__tests__/cx.test.js +47 -0
  10. package/dist/__tests__/cx.test.js.map +1 -0
  11. package/dist/__tests__/decorators.test.d.ts +2 -0
  12. package/dist/__tests__/decorators.test.d.ts.map +1 -0
  13. package/dist/__tests__/decorators.test.js +268 -0
  14. package/dist/__tests__/decorators.test.js.map +1 -0
  15. package/dist/__tests__/extract.test.d.ts +2 -0
  16. package/dist/__tests__/extract.test.d.ts.map +1 -0
  17. package/dist/__tests__/extract.test.js +420 -0
  18. package/dist/__tests__/extract.test.js.map +1 -0
  19. package/dist/__tests__/global.test.d.ts +2 -0
  20. package/dist/__tests__/global.test.d.ts.map +1 -0
  21. package/dist/__tests__/global.test.js +117 -0
  22. package/dist/__tests__/global.test.js.map +1 -0
  23. package/dist/__tests__/keyframes.test.d.ts +2 -0
  24. package/dist/__tests__/keyframes.test.d.ts.map +1 -0
  25. package/dist/__tests__/keyframes.test.js +90 -0
  26. package/dist/__tests__/keyframes.test.js.map +1 -0
  27. package/dist/__tests__/preflight.test.d.ts +2 -0
  28. package/dist/__tests__/preflight.test.d.ts.map +1 -0
  29. package/dist/__tests__/preflight.test.js +55 -0
  30. package/dist/__tests__/preflight.test.js.map +1 -0
  31. package/dist/__tests__/registry.test.d.ts +2 -0
  32. package/dist/__tests__/registry.test.d.ts.map +1 -0
  33. package/dist/__tests__/registry.test.js +132 -0
  34. package/dist/__tests__/registry.test.js.map +1 -0
  35. package/dist/__tests__/style.test.d.ts +2 -0
  36. package/dist/__tests__/style.test.d.ts.map +1 -0
  37. package/dist/__tests__/style.test.js +257 -0
  38. package/dist/__tests__/style.test.js.map +1 -0
  39. package/dist/__tests__/styled.test.d.ts +2 -0
  40. package/dist/__tests__/styled.test.d.ts.map +1 -0
  41. package/dist/__tests__/styled.test.js +951 -0
  42. package/dist/__tests__/styled.test.js.map +1 -0
  43. package/dist/__tests__/theme-instance.test.d.ts +2 -0
  44. package/dist/__tests__/theme-instance.test.d.ts.map +1 -0
  45. package/dist/__tests__/theme-instance.test.js +317 -0
  46. package/dist/__tests__/theme-instance.test.js.map +1 -0
  47. package/dist/__tests__/theme-uninit.test.d.ts +2 -0
  48. package/dist/__tests__/theme-uninit.test.d.ts.map +1 -0
  49. package/dist/__tests__/theme-uninit.test.js +10 -0
  50. package/dist/__tests__/theme-uninit.test.js.map +1 -0
  51. package/dist/__tests__/token-sheet.test.d.ts +2 -0
  52. package/dist/__tests__/token-sheet.test.d.ts.map +1 -0
  53. package/dist/__tests__/token-sheet.test.js +74 -0
  54. package/dist/__tests__/token-sheet.test.js.map +1 -0
  55. package/dist/base/stylesheet.d.ts +30 -0
  56. package/dist/base/stylesheet.d.ts.map +1 -0
  57. package/dist/base/stylesheet.js +29 -0
  58. package/dist/base/stylesheet.js.map +1 -0
  59. package/dist/builder/css-builder.d.ts +98 -0
  60. package/dist/builder/css-builder.d.ts.map +1 -0
  61. package/dist/builder/css-builder.js +79 -0
  62. package/dist/builder/css-builder.js.map +1 -0
  63. package/dist/builder/keyframes.d.ts +23 -0
  64. package/dist/builder/keyframes.d.ts.map +1 -0
  65. package/dist/builder/keyframes.js +54 -0
  66. package/dist/builder/keyframes.js.map +1 -0
  67. package/dist/cx.d.ts +3 -0
  68. package/dist/cx.d.ts.map +1 -0
  69. package/dist/cx.js +23 -0
  70. package/dist/cx.js.map +1 -0
  71. package/dist/decorators/param.d.ts +23 -0
  72. package/dist/decorators/param.d.ts.map +1 -0
  73. package/dist/decorators/param.js +39 -0
  74. package/dist/decorators/param.js.map +1 -0
  75. package/dist/decorators/style.d.ts +15 -0
  76. package/dist/decorators/style.d.ts.map +1 -0
  77. package/dist/decorators/style.js +53 -0
  78. package/dist/decorators/style.js.map +1 -0
  79. package/dist/decorators/styled.d.ts +37 -0
  80. package/dist/decorators/styled.d.ts.map +1 -0
  81. package/dist/decorators/styled.js +138 -0
  82. package/dist/decorators/styled.js.map +1 -0
  83. package/dist/extract.d.ts +14 -0
  84. package/dist/extract.d.ts.map +1 -0
  85. package/dist/extract.js +124 -0
  86. package/dist/extract.js.map +1 -0
  87. package/dist/global.d.ts +27 -0
  88. package/dist/global.d.ts.map +1 -0
  89. package/dist/global.js +49 -0
  90. package/dist/global.js.map +1 -0
  91. package/dist/index.d.ts +11 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +10 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/internal/collector.d.ts +15 -0
  96. package/dist/internal/collector.d.ts.map +1 -0
  97. package/dist/internal/collector.js +33 -0
  98. package/dist/internal/collector.js.map +1 -0
  99. package/dist/internal/hash.d.ts +5 -0
  100. package/dist/internal/hash.d.ts.map +1 -0
  101. package/dist/internal/hash.js +11 -0
  102. package/dist/internal/hash.js.map +1 -0
  103. package/dist/internal/registry.d.ts +3 -0
  104. package/dist/internal/registry.d.ts.map +1 -0
  105. package/dist/internal/registry.js +41 -0
  106. package/dist/internal/registry.js.map +1 -0
  107. package/dist/preflight.d.ts +18 -0
  108. package/dist/preflight.d.ts.map +1 -0
  109. package/dist/preflight.js +191 -0
  110. package/dist/preflight.js.map +1 -0
  111. package/dist/server.d.ts +8 -0
  112. package/dist/server.d.ts.map +1 -0
  113. package/dist/server.js +8 -0
  114. package/dist/server.js.map +1 -0
  115. package/dist/tokens/decorators.d.ts +41 -0
  116. package/dist/tokens/decorators.d.ts.map +1 -0
  117. package/dist/tokens/decorators.js +73 -0
  118. package/dist/tokens/decorators.js.map +1 -0
  119. package/dist/tokens/index.d.ts +4 -0
  120. package/dist/tokens/index.d.ts.map +1 -0
  121. package/dist/tokens/index.js +4 -0
  122. package/dist/tokens/index.js.map +1 -0
  123. package/dist/tokens/theme-instance.d.ts +33 -0
  124. package/dist/tokens/theme-instance.d.ts.map +1 -0
  125. package/dist/tokens/theme-instance.js +130 -0
  126. package/dist/tokens/theme-instance.js.map +1 -0
  127. package/dist/tokens/token-sheet.d.ts +46 -0
  128. package/dist/tokens/token-sheet.d.ts.map +1 -0
  129. package/dist/tokens/token-sheet.js +55 -0
  130. package/dist/tokens/token-sheet.js.map +1 -0
  131. package/package.json +33 -0
  132. package/src/__tests__/css-builder.test.ts +230 -0
  133. package/src/__tests__/cx.test.ts +63 -0
  134. package/src/__tests__/decorators.test.ts +127 -0
  135. package/src/__tests__/extract.test.ts +418 -0
  136. package/src/__tests__/global.test.ts +140 -0
  137. package/src/__tests__/keyframes.test.ts +103 -0
  138. package/src/__tests__/preflight.test.ts +63 -0
  139. package/src/__tests__/registry.test.ts +151 -0
  140. package/src/__tests__/style.test.ts +153 -0
  141. package/src/__tests__/styled.test.ts +427 -0
  142. package/src/__tests__/theme-instance.test.ts +374 -0
  143. package/src/__tests__/theme-uninit.test.ts +12 -0
  144. package/src/__tests__/token-sheet.test.ts +87 -0
  145. package/src/base/stylesheet.ts +35 -0
  146. package/src/builder/css-builder.ts +204 -0
  147. package/src/builder/keyframes.ts +66 -0
  148. package/src/cx.ts +26 -0
  149. package/src/decorators/param.ts +40 -0
  150. package/src/decorators/style.ts +55 -0
  151. package/src/decorators/styled.ts +167 -0
  152. package/src/extract.ts +133 -0
  153. package/src/global.ts +60 -0
  154. package/src/index.ts +10 -0
  155. package/src/internal/collector.ts +38 -0
  156. package/src/internal/hash.ts +10 -0
  157. package/src/internal/registry.ts +51 -0
  158. package/src/preflight.ts +192 -0
  159. package/src/server.ts +12 -0
  160. package/src/tokens/decorators.ts +108 -0
  161. package/src/tokens/index.ts +3 -0
  162. package/src/tokens/theme-instance.ts +149 -0
  163. package/src/tokens/token-sheet.ts +86 -0
  164. package/tsconfig.json +8 -0
@@ -0,0 +1,204 @@
1
+ import type { Properties } from "csstype";
2
+
3
+ // ─── CSSProperties ────────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * Typed CSS property object — backed by `csstype`.
7
+ * All ~300 CSS properties with full value autocomplete.
8
+ * Used as the argument to `this.css({...})` and all nesting methods.
9
+ */
10
+ export type CSSProperties = Properties<string | number> & Record<string, string | number | undefined>;
11
+
12
+ // ─── CSSBuilder ───────────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Fluent CSS builder returned by `this.css({...})`.
16
+ * TypeScript sees it as `string` — works in JSX `class=`, `cx()`, computed keys.
17
+ * Serialises to a CSS rule block via `toString()`.
18
+ *
19
+ * @example
20
+ * class BtnStyles extends Stylesheet {
21
+ * $base = this.css({ display: 'inline-flex', padding: '8px 16px' })
22
+ * .hover({ opacity: 0.9 })
23
+ * .disabled({ opacity: 0.4, pointerEvents: 'none' })
24
+ * .before({ content: '""', display: 'block' })
25
+ * .media('max-width: 640px', { width: '100%' })
26
+ * }
27
+ */
28
+ export type CSSBuilder = string & {
29
+ // ── Pseudo-classes — interaction ──────────────────────────────────────────
30
+ /** `&:hover { ... }` */
31
+ hover(props: CSSProperties): CSSBuilder;
32
+ /** `&:focus { ... }` */
33
+ focus(props: CSSProperties): CSSBuilder;
34
+ /** `&:focus-within { ... }` */
35
+ focusWithin(props: CSSProperties): CSSBuilder;
36
+ /** `&:focus-visible { ... }` */
37
+ focusVisible(props: CSSProperties): CSSBuilder;
38
+ /** `&:active { ... }` */
39
+ active(props: CSSProperties): CSSBuilder;
40
+ /** `&:visited { ... }` */
41
+ visited(props: CSSProperties): CSSBuilder;
42
+ /** `&:target { ... }` */
43
+ target(props: CSSProperties): CSSBuilder;
44
+
45
+ // ── Pseudo-classes — form state ───────────────────────────────────────────
46
+ /** `&:disabled, &[disabled], &[aria-disabled="true"] { ... }` */
47
+ disabled(props: CSSProperties): CSSBuilder;
48
+ /** `&:enabled { ... }` */
49
+ enabled(props: CSSProperties): CSSBuilder;
50
+ /** `&:checked { ... }` */
51
+ checked(props: CSSProperties): CSSBuilder;
52
+ /** `&:indeterminate { ... }` */
53
+ indeterminate(props: CSSProperties): CSSBuilder;
54
+ /** `&:required { ... }` */
55
+ required(props: CSSProperties): CSSBuilder;
56
+ /** `&:optional { ... }` */
57
+ optional(props: CSSProperties): CSSBuilder;
58
+ /** `&:valid { ... }` */
59
+ valid(props: CSSProperties): CSSBuilder;
60
+ /** `&:invalid { ... }` */
61
+ invalid(props: CSSProperties): CSSBuilder;
62
+ /** `&:read-only { ... }` */
63
+ readOnly(props: CSSProperties): CSSBuilder;
64
+
65
+ // ── Pseudo-classes — structural ───────────────────────────────────────────
66
+ /** `&:first-child { ... }` */
67
+ first(props: CSSProperties): CSSBuilder;
68
+ /** `&:last-child { ... }` */
69
+ last(props: CSSProperties): CSSBuilder;
70
+ /** `&:nth-child(n) { ... }` */
71
+ nthChild(n: string | number, props: CSSProperties): CSSBuilder;
72
+ /** `&:empty { ... }` */
73
+ empty(props: CSSProperties): CSSBuilder;
74
+
75
+ // ── Pseudo-classes — relational (CSS Selectors Level 4) ──────────────────
76
+ /** `&:has(selector) { ... }` */
77
+ has(selector: string, props: CSSProperties): CSSBuilder;
78
+ /** `&:is(...) { ... }` */
79
+ is(selector: string, props: CSSProperties): CSSBuilder;
80
+ /** `&:where(...) { ... }` — zero specificity */
81
+ where(selector: string, props: CSSProperties): CSSBuilder;
82
+ /** `&:not(selector) { ... }` */
83
+ not(selector: string, props: CSSProperties): CSSBuilder;
84
+
85
+ // ── Pseudo-elements ───────────────────────────────────────────────────────
86
+ /** `&::before { ... }` */
87
+ before(props: CSSProperties): CSSBuilder;
88
+ /** `&::after { ... }` */
89
+ after(props: CSSProperties): CSSBuilder;
90
+ /** `&::placeholder { ... }` */
91
+ placeholder(props: CSSProperties): CSSBuilder;
92
+ /** `&::selection { ... }` */
93
+ selection(props: CSSProperties): CSSBuilder;
94
+ /** `&::first-line { ... }` */
95
+ firstLine(props: CSSProperties): CSSBuilder;
96
+ /** `&::first-letter { ... }` */
97
+ firstLetter(props: CSSProperties): CSSBuilder;
98
+ /** `&::marker { ... }` */
99
+ marker(props: CSSProperties): CSSBuilder;
100
+ /** `&::backdrop { ... }` */
101
+ backdrop(props: CSSProperties): CSSBuilder;
102
+
103
+ // ── At-rules ──────────────────────────────────────────────────────────────
104
+ /** `@media (query) { ... }` */
105
+ media(query: string, props: CSSProperties): CSSBuilder;
106
+ /** `@container (query) { ... }` */
107
+ container(query: string, props: CSSProperties): CSSBuilder;
108
+ /** `@supports (condition) { ... }` */
109
+ supports(condition: string, props: CSSProperties): CSSBuilder;
110
+ /** Any arbitrary nested selector or at-rule. */
111
+ on(selector: string, props: CSSProperties): CSSBuilder;
112
+ };
113
+
114
+ // ─── Runtime ──────────────────────────────────────────────────────────────────
115
+
116
+ const CSS_BUILDER = Symbol("css-builder");
117
+
118
+ export function isCSSBuilder(value: unknown): boolean {
119
+ return typeof value === "object" && value !== null &&
120
+ (value as Record<symbol, unknown>)[CSS_BUILDER] === true;
121
+ }
122
+
123
+ function toKebab(camel: string): string {
124
+ return camel.replace(/([A-Z])/g, "-$1").toLowerCase();
125
+ }
126
+
127
+ function propsToCSS(props: CSSProperties): string {
128
+ return Object.entries(props)
129
+ .filter(([, v]) => v != null)
130
+ .map(([k, v]) => `${toKebab(k)}: ${String(v)};`)
131
+ .join("\n");
132
+ }
133
+
134
+ export function createCSSBuilder(initialProps: CSSProperties): CSSBuilder {
135
+ const parts: string[] = [];
136
+ const rootCSS = propsToCSS(initialProps);
137
+ if (rootCSS) parts.push(rootCSS);
138
+
139
+ const addNested = (selector: string, props: CSSProperties): CSSBuilder => {
140
+ const rawCSS = propsToCSS(props);
141
+ if (!rawCSS) return builder;
142
+ const inner = rawCSS.split("\n").map((l) => ` ${l}`).join("\n");
143
+ parts.push(`${selector} {\n${inner}\n}`);
144
+ return builder;
145
+ };
146
+
147
+ const toCSS = (): string => parts.join("\n");
148
+
149
+ const builder = Object.assign(Object.create(null) as object, {
150
+ [CSS_BUILDER]: true,
151
+ toString: toCSS,
152
+ [Symbol.toPrimitive]: toCSS,
153
+
154
+ // Pseudo-classes — interaction
155
+ hover: (p: CSSProperties) => addNested("&:hover", p),
156
+ focus: (p: CSSProperties) => addNested("&:focus", p),
157
+ focusWithin: (p: CSSProperties) => addNested("&:focus-within", p),
158
+ focusVisible: (p: CSSProperties) => addNested("&:focus-visible", p),
159
+ active: (p: CSSProperties) => addNested("&:active", p),
160
+ visited: (p: CSSProperties) => addNested("&:visited", p),
161
+ target: (p: CSSProperties) => addNested("&:target", p),
162
+
163
+ // Pseudo-classes — form state
164
+ disabled: (p: CSSProperties) => addNested('&:disabled, &[disabled], &[aria-disabled="true"]', p),
165
+ enabled: (p: CSSProperties) => addNested("&:enabled", p),
166
+ checked: (p: CSSProperties) => addNested("&:checked", p),
167
+ indeterminate:(p: CSSProperties) => addNested("&:indeterminate", p),
168
+ required: (p: CSSProperties) => addNested("&:required", p),
169
+ optional: (p: CSSProperties) => addNested("&:optional", p),
170
+ valid: (p: CSSProperties) => addNested("&:valid", p),
171
+ invalid: (p: CSSProperties) => addNested("&:invalid", p),
172
+ readOnly: (p: CSSProperties) => addNested("&:read-only", p),
173
+
174
+ // Pseudo-classes — structural
175
+ first: (p: CSSProperties) => addNested("&:first-child", p),
176
+ last: (p: CSSProperties) => addNested("&:last-child", p),
177
+ nthChild: (n: string | number, p: CSSProperties) => addNested(`&:nth-child(${String(n)})`, p),
178
+ empty: (p: CSSProperties) => addNested("&:empty", p),
179
+
180
+ // Pseudo-classes — relational (Selectors Level 4)
181
+ has: (sel: string, p: CSSProperties) => addNested(`&:has(${sel})`, p),
182
+ is: (sel: string, p: CSSProperties) => addNested(`&:is(${sel})`, p),
183
+ where: (sel: string, p: CSSProperties) => addNested(`&:where(${sel})`, p),
184
+ not: (sel: string, p: CSSProperties) => addNested(`&:not(${sel})`, p),
185
+
186
+ // Pseudo-elements
187
+ before: (p: CSSProperties) => addNested("&::before", p),
188
+ after: (p: CSSProperties) => addNested("&::after", p),
189
+ placeholder: (p: CSSProperties) => addNested("&::placeholder", p),
190
+ selection: (p: CSSProperties) => addNested("&::selection", p),
191
+ firstLine: (p: CSSProperties) => addNested("&::first-line", p),
192
+ firstLetter: (p: CSSProperties) => addNested("&::first-letter", p),
193
+ marker: (p: CSSProperties) => addNested("&::marker", p),
194
+ backdrop: (p: CSSProperties) => addNested("&::backdrop", p),
195
+
196
+ // At-rules
197
+ media: (q: string, p: CSSProperties) => addNested(`@media (${q})`, p),
198
+ container: (q: string, p: CSSProperties) => addNested(`@container (${q})`, p),
199
+ supports: (cond: string, p: CSSProperties) => addNested(`@supports (${cond})`, p),
200
+ on: (sel: string, p: CSSProperties) => addNested(sel, p),
201
+ }) as unknown as CSSBuilder;
202
+
203
+ return builder;
204
+ }
@@ -0,0 +1,66 @@
1
+ import { getCollector } from "../internal/collector.js";
2
+ import { hashCSS } from "../internal/hash.js";
3
+
4
+ import type { CSSProperties } from "./css-builder.js";
5
+
6
+ // Defined by the Vite CSS plugin — skip DOM injection when CSS is static.
7
+ declare const __PRAXIS_CSS_STATIC__: boolean | undefined;
8
+ const STATIC_MODE: boolean =
9
+ typeof __PRAXIS_CSS_STATIC__ !== "undefined" && __PRAXIS_CSS_STATIC__;
10
+
11
+ type KeyframeStops = Record<string, CSSProperties>;
12
+
13
+ const injected = new Set<string>();
14
+
15
+ function toKebab(camel: string): string {
16
+ return camel.replace(/([A-Z])/g, "-$1").toLowerCase();
17
+ }
18
+
19
+ function stopToCSS(props: CSSProperties): string {
20
+ return Object.entries(props)
21
+ .filter(([, v]) => v != null)
22
+ .map(([k, v]) => ` ${toKebab(k)}: ${String(v)};`)
23
+ .join("\n");
24
+ }
25
+
26
+ /**
27
+ * Defines a scoped `@keyframes` animation and injects it into `document.head`.
28
+ * Returns the scoped animation name — use it in the `animation` or
29
+ * `animationName` property of a stylesheet field.
30
+ *
31
+ * The name is content-hashed so two identical animations share one injection.
32
+ * Safe to call at module level (outside components).
33
+ *
34
+ * @example
35
+ * const pulse = keyframes('pulse', {
36
+ * from: { opacity: 1 },
37
+ * to: { opacity: 0.4 },
38
+ * })
39
+ *
40
+ * class LoaderStyles extends Stylesheet {
41
+ * $spinner = this.css({ animation: `${pulse} 1.2s ease-in-out infinite` })
42
+ * }
43
+ */
44
+ export function keyframes(name: string, stops: KeyframeStops): string {
45
+ const stopsCSS = Object.entries(stops)
46
+ .map(([stop, props]) => ` ${stop} {\n${stopToCSS(props)}\n }`)
47
+ .join("\n");
48
+
49
+ const scopedName = `prx-${name}-${hashCSS(stopsCSS)}`;
50
+ const kfCSS = `@keyframes ${scopedName} {\n${stopsCSS}\n}`;
51
+
52
+ if (STATIC_MODE) return scopedName;
53
+
54
+ const col = getCollector();
55
+ if (col) { col.addKeyframes(scopedName, kfCSS); return scopedName; }
56
+
57
+ if (!injected.has(scopedName) && typeof document !== "undefined") {
58
+ const el = document.createElement("style");
59
+ el.setAttribute("data-praxis-keyframes", scopedName);
60
+ el.textContent = kfCSS;
61
+ document.head.appendChild(el);
62
+ injected.add(scopedName);
63
+ }
64
+
65
+ return scopedName;
66
+ }
package/src/cx.ts ADDED
@@ -0,0 +1,26 @@
1
+ export type ClassValue =
2
+ | string
3
+ | number
4
+ | boolean
5
+ | null
6
+ | undefined
7
+ | ClassValue[]
8
+ | Record<string, unknown>;
9
+
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
+ }
23
+ }
24
+ }
25
+ return classes.join(" ");
26
+ }
@@ -0,0 +1,40 @@
1
+ import { createFieldDecorator } from "@praxisjs/decorators";
2
+
3
+ import type { ReactiveStylesheet } from "../base/stylesheet.js";
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
+ /**
12
+ * Marks a field in a {@link ReactiveStylesheet} subclass as a reactive CSS custom property.
13
+ * The field name becomes `--fieldName` on the component's container element.
14
+ * Setting the field updates the CSS var reactively — zero re-renders.
15
+ *
16
+ * Only valid inside a `ReactiveStylesheet` subclass. TypeScript enforces this at
17
+ * compile time via the `createFieldDecorator<ReactiveStylesheet>` constraint.
18
+ *
19
+ * @example
20
+ * class CardStyles extends ReactiveStylesheet {
21
+ * @Param() color = '#3b82f6' // → --color on the element
22
+ * @Param() radius = '8px' // → --radius on the element
23
+ *
24
+ * $root = `border-radius: var(--radius); background: var(--color);`
25
+ * }
26
+ *
27
+ * // In the component:
28
+ * this.$card.color = '#ef4444' // updates --color reactively
29
+ */
30
+ 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
+ });
40
+ }
@@ -0,0 +1,55 @@
1
+ import { effect, signal, type RootComponent } from "@praxisjs/core/internal";
2
+ import { createFieldDecorator } from "@praxisjs/decorators";
3
+
4
+ /**
5
+ * Reactive field decorator that binds a signal value to a CSS custom property
6
+ * on the component's container element — with zero re-renders.
7
+ *
8
+ * Only CSS custom properties (variables) are supported: the property name must
9
+ * start with `--`.
10
+ *
11
+ * @example
12
+ * class ThemeCard extends StatefulComponent {
13
+ * @Style('--accent') accent = '#3b82f6'
14
+ * @Style('--radius') radius = '8px'
15
+ * }
16
+ */
17
+ 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
+ // _anchor is the "[/ComponentName]" comment node inserted by the runtime.
35
+ // Its parentElement is the container where the component lives.
36
+ const el = (instance as unknown as RootComponent)._anchor
37
+ ?.parentElement as HTMLElement | null;
38
+ if (!el) return;
39
+
40
+ stopEffect = effect(() => {
41
+ el.style.setProperty(property, String(sig()));
42
+ });
43
+ },
44
+
45
+ onUnmount() {
46
+ stopEffect?.();
47
+ stopEffect = null;
48
+ const el = (instance as unknown as RootComponent)._anchor
49
+ ?.parentElement as HTMLElement | null;
50
+ if (el) el.style.removeProperty(property);
51
+ },
52
+ };
53
+ },
54
+ });
55
+ }
@@ -0,0 +1,167 @@
1
+ import type { StatefulComponent } from "@praxisjs/core";
2
+ import { effect, signal, type RootComponent } 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";
9
+
10
+ import type { ReactiveStylesheet, Stylesheet } from "../base/stylesheet.js";
11
+
12
+ // ─── Class data cache ─────────────────────────────────────────────────────────
13
+
14
+ interface StyleClassData {
15
+ classMap: Readonly<Record<string, string>>;
16
+ rawCSS: string;
17
+ registryKey: string;
18
+ paramDefaults: Readonly<Record<string, string | number>>;
19
+ }
20
+
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;
47
+ }
48
+ }
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;
58
+ }
59
+
60
+ // ─── @Styled field decorator ──────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Scoped CSS field decorator. Receives a {@link Stylesheet} or
64
+ * {@link ReactiveStylesheet} subclass and builds a stylesheet object with:
65
+ *
66
+ * - `$xxx` fields → scoped class name strings (static, cached per class)
67
+ * - `@Param() xxx` fields → reactive CSS custom property setters
68
+ * (requires {@link ReactiveStylesheet} and a `StatefulComponent` field)
69
+ *
70
+ * **TypeScript enforcement:**
71
+ * - `@Styled(ReactiveStylesheetSubclass)` can only be applied to fields of
72
+ * classes that extend `StatefulComponent`.
73
+ * - `@Styled(StylesheetSubclass)` (no `@Param()`) can be applied to any component.
74
+ *
75
+ * @example — static CSS (any component)
76
+ * class IconStyles extends Stylesheet {
77
+ * $root = `display: inline-flex; align-items: center;`
78
+ * }
79
+ *
80
+ * class Icon extends StatelessComponent {
81
+ * @Styled(IconStyles) $s!: IconStyles
82
+ * }
83
+ *
84
+ * @example — reactive CSS vars (StatefulComponent only)
85
+ * class CardStyles extends ReactiveStylesheet {
86
+ * @Param() color = '#3b82f6'
87
+ * $root = `border: 2px solid var(--color);`
88
+ * }
89
+ *
90
+ * @Component()
91
+ * class Card extends StatefulComponent {
92
+ * @Styled(CardStyles) $card!: CardStyles
93
+ * }
94
+ */
95
+ export function Styled<T extends Stylesheet>(
96
+ StyleClass: new () => T,
97
+ ): [T] extends [ReactiveStylesheet]
98
+ ? (value: undefined, context: ClassFieldDecoratorContext<StatefulComponent, T>) => void
99
+ : (value: undefined, context: ClassFieldDecoratorContext<object, T>) => void {
100
+ const data = getStyleClassData(StyleClass);
101
+
102
+ // Eagerly inject CSS at decoration time (module load) so it is in the DOM
103
+ // 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
+ });
127
+ }
128
+
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 = (instance as unknown as RootComponent)._anchor?.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 = (instance as unknown as RootComponent)._anchor?.parentElement;
157
+ if (el) {
158
+ for (const paramName of Object.keys(paramSignals)) {
159
+ el.style.removeProperty(`--${paramName}`);
160
+ }
161
+ }
162
+ }
163
+ },
164
+ };
165
+ },
166
+ });
167
+ }