@pyreon/styler 0.11.0 → 0.11.2

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 (36) hide show
  1. package/package.json +12 -10
  2. package/src/ThemeProvider.ts +37 -0
  3. package/src/__tests__/ThemeProvider.test.ts +67 -0
  4. package/src/__tests__/benchmark.bench.ts +189 -0
  5. package/src/__tests__/composition-chain.test.ts +489 -0
  6. package/src/__tests__/css.test.ts +70 -0
  7. package/src/__tests__/forward.test.ts +282 -0
  8. package/src/__tests__/globalStyle.test.ts +72 -0
  9. package/src/__tests__/hash.test.ts +70 -0
  10. package/src/__tests__/hybrid-injection.test.ts +205 -0
  11. package/src/__tests__/index.ts +14 -0
  12. package/src/__tests__/insertion-effect.test.ts +106 -0
  13. package/src/__tests__/integration.test.ts +149 -0
  14. package/src/__tests__/keyframes.test.ts +68 -0
  15. package/src/__tests__/memory-growth.test.ts +152 -0
  16. package/src/__tests__/p3-features.test.ts +258 -0
  17. package/src/__tests__/resolve.test.ts +249 -0
  18. package/src/__tests__/shared.test.ts +73 -0
  19. package/src/__tests__/sheet-advanced.test.ts +669 -0
  20. package/src/__tests__/sheet-split-atrules.test.ts +411 -0
  21. package/src/__tests__/sheet.test.ts +164 -0
  22. package/src/__tests__/styled-ssr.test.ts +67 -0
  23. package/src/__tests__/styled.test.ts +303 -0
  24. package/src/__tests__/theme.test.ts +33 -0
  25. package/src/__tests__/useCSS.test.ts +142 -0
  26. package/src/css.ts +13 -0
  27. package/src/forward.ts +276 -0
  28. package/src/globalStyle.ts +48 -0
  29. package/src/hash.ts +30 -0
  30. package/src/index.ts +15 -0
  31. package/src/keyframes.ts +36 -0
  32. package/src/resolve.ts +172 -0
  33. package/src/shared.ts +12 -0
  34. package/src/sheet.ts +387 -0
  35. package/src/styled.tsx +277 -0
  36. package/src/useCSS.ts +20 -0
@@ -0,0 +1,282 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { buildProps, filterProps } from "../forward"
3
+
4
+ describe("filterProps", () => {
5
+ describe("keeps standard HTML props", () => {
6
+ it("keeps id", () => {
7
+ const result = filterProps({ id: "test" })
8
+ expect(result.id).toBe("test")
9
+ })
10
+
11
+ it("keeps className", () => {
12
+ const result = filterProps({ className: "foo" })
13
+ expect(result.className).toBe("foo")
14
+ })
15
+
16
+ it("keeps style", () => {
17
+ const style = { color: "red" }
18
+ const result = filterProps({ style })
19
+ expect(result.style).toBe(style)
20
+ })
21
+
22
+ it("keeps href", () => {
23
+ const result = filterProps({ href: "/path" })
24
+ expect(result.href).toBe("/path")
25
+ })
26
+
27
+ it("keeps disabled", () => {
28
+ const result = filterProps({ disabled: true })
29
+ expect(result.disabled).toBe(true)
30
+ })
31
+
32
+ it("keeps multiple standard props at once", () => {
33
+ const result = filterProps({
34
+ id: "main",
35
+ tabIndex: 0,
36
+ role: "button",
37
+ title: "Click me",
38
+ })
39
+ expect(result).toEqual({
40
+ id: "main",
41
+ tabIndex: 0,
42
+ role: "button",
43
+ title: "Click me",
44
+ })
45
+ })
46
+ })
47
+
48
+ describe("keeps data-* attributes", () => {
49
+ it("keeps data-testid", () => {
50
+ const result = filterProps({ "data-testid": "hello" })
51
+ expect(result["data-testid"]).toBe("hello")
52
+ })
53
+
54
+ it("keeps data-custom", () => {
55
+ const result = filterProps({ "data-custom": "value" })
56
+ expect(result["data-custom"]).toBe("value")
57
+ })
58
+ })
59
+
60
+ describe("keeps aria-* attributes", () => {
61
+ it("keeps aria-label", () => {
62
+ const result = filterProps({ "aria-label": "Close" })
63
+ expect(result["aria-label"]).toBe("Close")
64
+ })
65
+
66
+ it("keeps aria-hidden", () => {
67
+ const result = filterProps({ "aria-hidden": true })
68
+ expect(result["aria-hidden"]).toBe(true)
69
+ })
70
+
71
+ it("keeps aria-describedby", () => {
72
+ const result = filterProps({ "aria-describedby": "desc" })
73
+ expect(result["aria-describedby"]).toBe("desc")
74
+ })
75
+ })
76
+
77
+ describe("keeps event handlers", () => {
78
+ it("keeps onClick", () => {
79
+ const fn = () => undefined
80
+ const result = filterProps({ onClick: fn })
81
+ expect(result.onClick).toBe(fn)
82
+ })
83
+
84
+ it("keeps onMouseEnter", () => {
85
+ const fn = () => undefined
86
+ const result = filterProps({ onMouseEnter: fn })
87
+ expect(result.onMouseEnter).toBe(fn)
88
+ })
89
+
90
+ it("keeps onKeyDown", () => {
91
+ const fn = () => undefined
92
+ const result = filterProps({ onKeyDown: fn })
93
+ expect(result.onKeyDown).toBe(fn)
94
+ })
95
+ })
96
+
97
+ describe("strips $-prefixed transient props", () => {
98
+ it("strips $rocketstyle", () => {
99
+ const result = filterProps({ $rocketstyle: { color: "red" } })
100
+ expect(result).toEqual({})
101
+ })
102
+
103
+ it("strips $element", () => {
104
+ const result = filterProps({ $element: "button" })
105
+ expect(result).toEqual({})
106
+ })
107
+
108
+ it("strips $active", () => {
109
+ const result = filterProps({ $active: true })
110
+ expect(result).toEqual({})
111
+ })
112
+
113
+ it("strips multiple $-prefixed props while keeping valid ones", () => {
114
+ const result = filterProps({
115
+ $rocketstyle: {},
116
+ $element: "div",
117
+ id: "test",
118
+ "data-x": "y",
119
+ })
120
+ expect(result).toEqual({ id: "test", "data-x": "y" })
121
+ })
122
+ })
123
+
124
+ describe("strips as prop", () => {
125
+ it("strips the as prop", () => {
126
+ const result = filterProps({ as: "button" })
127
+ expect(result).toEqual({})
128
+ })
129
+
130
+ it("strips as while keeping other props", () => {
131
+ const result = filterProps({ as: "section", id: "main" })
132
+ expect(result).toEqual({ id: "main" })
133
+ })
134
+ })
135
+
136
+ describe("strips unknown props", () => {
137
+ it("strips customProp", () => {
138
+ const result = filterProps({ customProp: "value" })
139
+ expect(result).toEqual({})
140
+ })
141
+
142
+ it("strips myThing", () => {
143
+ const result = filterProps({ myThing: 42 })
144
+ expect(result).toEqual({})
145
+ })
146
+
147
+ it("strips camelCase unknown props", () => {
148
+ const result = filterProps({ isActive: true, backgroundColor: "red" })
149
+ expect(result).toEqual({})
150
+ })
151
+
152
+ it("returns empty object for all-unknown props", () => {
153
+ const result = filterProps({
154
+ foo: 1,
155
+ bar: 2,
156
+ baz: 3,
157
+ customThing: "x",
158
+ })
159
+ expect(result).toEqual({})
160
+ })
161
+ })
162
+ })
163
+
164
+ describe("buildProps", () => {
165
+ describe("className merging", () => {
166
+ it("merges generatedCls with user className", () => {
167
+ const result = buildProps({ className: "custom" }, "pyr-abc", true)
168
+ expect(result.class).toBe("pyr-abc custom")
169
+ })
170
+
171
+ it("merges generatedCls with user class", () => {
172
+ const result = buildProps({ class: "custom" }, "pyr-abc", true)
173
+ expect(result.class).toBe("pyr-abc custom")
174
+ })
175
+
176
+ it("uses only generatedCls when no user className", () => {
177
+ const result = buildProps({}, "pyr-abc", true)
178
+ expect(result.class).toBe("pyr-abc")
179
+ })
180
+
181
+ it("uses only user className when generatedCls is empty", () => {
182
+ const result = buildProps({ className: "custom" }, "", true)
183
+ expect(result.class).toBe("custom")
184
+ })
185
+
186
+ it("sets no class when both are empty/missing", () => {
187
+ const result = buildProps({}, "", true)
188
+ expect(result.class).toBeUndefined()
189
+ })
190
+ })
191
+
192
+ describe("isDOM=false (component target)", () => {
193
+ it("forwards all props except as, className, class, and $-prefixed", () => {
194
+ const result = buildProps(
195
+ {
196
+ as: "button",
197
+ className: "user",
198
+ customProp: "hello",
199
+ $rocketstyle: {},
200
+ $rocketstate: { hover: true },
201
+ "data-x": "y",
202
+ onClick: () => undefined,
203
+ },
204
+ "pyr-abc",
205
+ false,
206
+ )
207
+
208
+ expect(result.customProp).toBe("hello")
209
+ expect(result.$rocketstyle).toBeUndefined()
210
+ expect(result.$rocketstate).toBeUndefined()
211
+ expect(result["data-x"]).toBe("y")
212
+ expect(result.onClick).toBeDefined()
213
+ // as and className are not forwarded from rawProps (class is merged separately)
214
+ expect(result.as).toBeUndefined()
215
+ expect(result.class).toBe("pyr-abc user")
216
+ })
217
+ })
218
+
219
+ describe("isDOM=true (default filtering)", () => {
220
+ it("filters unknown DOM props", () => {
221
+ const result = buildProps({ customProp: "hello", unknownThing: 42 }, "pyr-abc", true)
222
+ expect(result.customProp).toBeUndefined()
223
+ expect(result.unknownThing).toBeUndefined()
224
+ expect(result.class).toBe("pyr-abc")
225
+ })
226
+
227
+ it("strips $-prefixed props", () => {
228
+ const result = buildProps({ $rocketstyle: {}, $active: true, id: "test" }, "pyr-abc", true)
229
+ expect(result.$rocketstyle).toBeUndefined()
230
+ expect(result.$active).toBeUndefined()
231
+ expect(result.id).toBe("test")
232
+ })
233
+
234
+ it("keeps data-* and aria-* attributes", () => {
235
+ const result = buildProps({ "data-testid": "btn", "aria-label": "Close" }, "pyr-abc", true)
236
+ expect(result["data-testid"]).toBe("btn")
237
+ expect(result["aria-label"]).toBe("Close")
238
+ })
239
+
240
+ it("keeps standard HTML attributes", () => {
241
+ const result = buildProps({ id: "main", disabled: true, tabIndex: 0 }, "pyr-abc", true)
242
+ expect(result.id).toBe("main")
243
+ expect(result.disabled).toBe(true)
244
+ expect(result.tabIndex).toBe(0)
245
+ })
246
+
247
+ it("strips as prop", () => {
248
+ const result = buildProps({ as: "section" }, "pyr-abc", true)
249
+ expect(result.as).toBeUndefined()
250
+ })
251
+ })
252
+
253
+ describe("isDOM=true with customFilter", () => {
254
+ it("uses customFilter to decide which props to forward", () => {
255
+ const customFilter = (prop: string) => prop.startsWith("my")
256
+ const result = buildProps(
257
+ { myProp: "yes", otherProp: "no", id: "skip" },
258
+ "pyr-abc",
259
+ true,
260
+ customFilter,
261
+ )
262
+ expect(result.myProp).toBe("yes")
263
+ expect(result.otherProp).toBeUndefined()
264
+ expect(result.id).toBeUndefined()
265
+ })
266
+
267
+ it("customFilter still skips as and className from rawProps", () => {
268
+ const customFilter = () => true
269
+ const result = buildProps(
270
+ { as: "button", className: "user", id: "test" },
271
+ "pyr-abc",
272
+ true,
273
+ customFilter,
274
+ )
275
+ // as is always skipped
276
+ expect(result.as).toBeUndefined()
277
+ // class is merged, not forwarded from rawProps
278
+ expect(result.class).toBe("pyr-abc user")
279
+ expect(result.id).toBe("test")
280
+ })
281
+ })
282
+ })
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { createGlobalStyle } from "../globalStyle"
3
+ import { sheet } from "../sheet"
4
+
5
+ describe("createGlobalStyle -- empty CSS paths", () => {
6
+ afterEach(() => {
7
+ sheet.clearAll()
8
+ })
9
+
10
+ it("static: returns null for empty template", () => {
11
+ const GlobalStyle = createGlobalStyle``
12
+ const result = GlobalStyle({})
13
+ expect(result).toBeNull()
14
+ })
15
+
16
+ it("static: returns null for whitespace-only template", () => {
17
+ const GlobalStyle = createGlobalStyle` `
18
+ const result = GlobalStyle({})
19
+ expect(result).toBeNull()
20
+ })
21
+
22
+ it("dynamic: returns null when interpolation resolves to empty CSS", () => {
23
+ const GlobalStyle = createGlobalStyle`${({ theme }: any) => (theme.empty ? "" : "")}`
24
+ const result = GlobalStyle({})
25
+ expect(result).toBeNull()
26
+ })
27
+
28
+ it("dynamic: returns null when interpolation resolves to whitespace", () => {
29
+ const GlobalStyle = createGlobalStyle`${() => " "}`
30
+ const result = GlobalStyle({})
31
+ expect(result).toBeNull()
32
+ })
33
+ })
34
+
35
+ describe("createGlobalStyle", () => {
36
+ afterEach(() => {
37
+ sheet.clearAll()
38
+ })
39
+
40
+ it("returns a component function", () => {
41
+ const GlobalStyle = createGlobalStyle`
42
+ body { margin: 0; }
43
+ `
44
+ expect(typeof GlobalStyle).toBe("function")
45
+ })
46
+
47
+ it("renders nothing (returns null)", () => {
48
+ const GlobalStyle = createGlobalStyle`
49
+ body { margin: 0; padding: 0; }
50
+ `
51
+ const result = GlobalStyle({})
52
+ expect(result).toBeNull()
53
+ })
54
+
55
+ it("handles dynamic interpolations with theme", () => {
56
+ // Dynamic path: function interpolation causes per-render resolution
57
+ const GlobalStyle = createGlobalStyle`
58
+ body { font-family: ${({ theme }: any) => theme?.font ?? "sans-serif"}; }
59
+ `
60
+ const result = GlobalStyle({})
61
+ expect(result).toBeNull()
62
+ })
63
+
64
+ it("handles static interpolations", () => {
65
+ const color = "red"
66
+ const GlobalStyle = createGlobalStyle`
67
+ body { color: ${color}; }
68
+ `
69
+ const result = GlobalStyle({})
70
+ expect(result).toBeNull()
71
+ })
72
+ })
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { hash } from "../hash"
3
+
4
+ describe("hash", () => {
5
+ it("returns a string", () => {
6
+ expect(typeof hash("test")).toBe("string")
7
+ })
8
+
9
+ it("is deterministic — same input always produces same output", () => {
10
+ const input = "display: flex; color: red;"
11
+ expect(hash(input)).toBe(hash(input))
12
+ })
13
+
14
+ it("produces different hashes for different inputs", () => {
15
+ expect(hash("color: red")).not.toBe(hash("color: blue"))
16
+ })
17
+
18
+ it("returns base-36 string (compact)", () => {
19
+ const result = hash("some css")
20
+ expect(result).toMatch(/^[0-9a-z]+$/)
21
+ })
22
+
23
+ it("handles empty string", () => {
24
+ const result = hash("")
25
+ expect(typeof result).toBe("string")
26
+ expect(result.length).toBeGreaterThan(0)
27
+ })
28
+
29
+ it("handles long CSS strings", () => {
30
+ const longCSS = "display: flex; ".repeat(100)
31
+ const result = hash(longCSS)
32
+ expect(typeof result).toBe("string")
33
+ // base-36 uint32 is at most 7 chars
34
+ expect(result.length).toBeLessThan(10)
35
+ })
36
+
37
+ it("handles special characters in CSS", () => {
38
+ const css = `@media (min-width: 48em) { .foo { content: "hello"; } }`
39
+ const result = hash(css)
40
+ expect(typeof result).toBe("string")
41
+ })
42
+
43
+ it("produces consistent hash for FNV-1a offset basis on empty string", () => {
44
+ // Empty string: h stays at FNV_OFFSET = 2166136261, base36 = "zzzzzz" range
45
+ const result = hash("")
46
+ // Just verify it is stable
47
+ expect(result).toBe(hash(""))
48
+ })
49
+
50
+ it("handles unicode characters", () => {
51
+ const result = hash('content: "🎉";')
52
+ expect(typeof result).toBe("string")
53
+ expect(result.length).toBeGreaterThan(0)
54
+ })
55
+
56
+ it("single character inputs produce distinct hashes", () => {
57
+ const hashes = new Set<string>()
58
+ for (let i = 0; i < 26; i++) {
59
+ hashes.add(hash(String.fromCharCode(97 + i)))
60
+ }
61
+ // All 26 lowercase letters should hash to unique values
62
+ expect(hashes.size).toBe(26)
63
+ })
64
+
65
+ it("hash is unsigned 32-bit (no negative values)", () => {
66
+ // base-36 of a uint32 is always positive
67
+ const result = hash("test negative")
68
+ expect(Number.parseInt(result, 36)).toBeGreaterThanOrEqual(0)
69
+ })
70
+ })
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Tests for the hybrid injection approach:
3
+ * - Client (jsdom): shared <style data-pyreon-styler> sheet
4
+ * - CSS rules present in the CSSOM sheet after insertion
5
+ * - `boost` option threaded from styled() through to the sheet
6
+ *
7
+ * Ported to VNode-level testing: we call the component function directly
8
+ * and inspect the returned VNode + the sheet's CSSOM.
9
+ */
10
+
11
+ import type { VNode } from "@pyreon/core"
12
+ import { afterEach, describe, expect, it } from "vitest"
13
+ import { createGlobalStyle } from "../globalStyle"
14
+ import { sheet } from "../sheet"
15
+ import { styled } from "../styled"
16
+
17
+ /** Helper: collect all CSS rule texts from the shared <style data-pyreon-styler> sheet. */
18
+ const getSheetRules = (): string[] => {
19
+ const el = document.querySelector("style[data-pyreon-styler]") as HTMLStyleElement | null
20
+ if (!el?.sheet) return []
21
+ return Array.from(el.sheet.cssRules).map((r) => r.cssText)
22
+ }
23
+
24
+ /** Helper: find rules matching a className in the CSSOM. */
25
+ const findRulesFor = (className: string): string[] =>
26
+ getSheetRules().filter((r) => r.includes(`.${className}`))
27
+
28
+ describe("hybrid injection — CSS in shared sheet", () => {
29
+ afterEach(() => {
30
+ sheet.clearAll()
31
+ })
32
+
33
+ describe("static styled components", () => {
34
+ it("injects CSS rules into the shared <style data-pyreon-styler> element", () => {
35
+ const Comp = styled("div")`color: red;`
36
+ const vnode = Comp({}) as VNode
37
+ const className = vnode.props.class as string
38
+
39
+ const rules = findRulesFor(className)
40
+ expect(rules.length).toBeGreaterThanOrEqual(1)
41
+ expect(rules.some((r) => r.includes("color: red"))).toBe(true)
42
+ })
43
+
44
+ it("multiple static components share the same <style> element", () => {
45
+ const A = styled("div")`color: red;`
46
+ const B = styled("span")`font-size: 20px;`
47
+
48
+ A({})
49
+ B({})
50
+
51
+ // Both should be in the same sheet
52
+ const styleEls = document.querySelectorAll("style[data-pyreon-styler]")
53
+ expect(styleEls.length).toBe(1)
54
+
55
+ const rules = getSheetRules()
56
+ expect(rules.some((r) => r.includes("color: red"))).toBe(true)
57
+ expect(rules.some((r) => r.includes("font-size: 20px"))).toBe(true)
58
+ })
59
+ })
60
+
61
+ describe("dynamic styled components", () => {
62
+ it("injects CSS into the shared sheet", () => {
63
+ const Comp = styled("div")`color: ${(p: any) => p.$color};`
64
+ const vnode = Comp({ $color: "blue" }) as VNode
65
+ const className = vnode.props.class as string
66
+
67
+ const rules = findRulesFor(className)
68
+ expect(rules.length).toBeGreaterThanOrEqual(1)
69
+ expect(rules.some((r) => r.includes("color: blue"))).toBe(true)
70
+ })
71
+
72
+ it("different prop values inject different CSS rules into the sheet", () => {
73
+ const Comp = styled("div")`color: ${(p: any) => p.$color};`
74
+ const vnode1 = Comp({ $color: "red" }) as VNode
75
+ const cls1 = vnode1.props.class as string
76
+
77
+ const vnode2 = Comp({ $color: "green" }) as VNode
78
+ const cls2 = vnode2.props.class as string
79
+
80
+ expect(cls1).not.toBe(cls2)
81
+
82
+ // Both rules should be in the sheet
83
+ expect(findRulesFor(cls1).some((r) => r.includes("color: red"))).toBe(true)
84
+ expect(findRulesFor(cls2).some((r) => r.includes("color: green"))).toBe(true)
85
+ })
86
+ })
87
+
88
+ describe("createGlobalStyle", () => {
89
+ it("static global styles are injected into the shared sheet", () => {
90
+ const GlobalStyle = createGlobalStyle`
91
+ body { margin: 0; }
92
+ `
93
+ // Static global styles are injected at creation time
94
+ GlobalStyle({})
95
+
96
+ const rules = getSheetRules()
97
+ expect(rules.some((r) => r.includes("margin") && r.includes("0"))).toBe(true)
98
+ })
99
+ })
100
+ })
101
+
102
+ describe("hybrid injection — VNode output (no <style> in tree)", () => {
103
+ afterEach(() => {
104
+ sheet.clearAll()
105
+ })
106
+
107
+ describe("styled components", () => {
108
+ it("static component returns a VNode of the correct tag", () => {
109
+ const Comp = styled("div")`color: red;`
110
+ const vnode = Comp({}) as VNode
111
+
112
+ // Should return a VNode for <div>, not a <style>
113
+ expect(vnode.type).toBe("div")
114
+ })
115
+
116
+ it("dynamic component returns a VNode of the correct tag", () => {
117
+ const Comp = styled("div")`color: ${(p: any) => p.$color};`
118
+ const vnode = Comp({ $color: "red" }) as VNode
119
+
120
+ expect(vnode.type).toBe("div")
121
+ })
122
+
123
+ it("multiple styled components produce correct VNode types", () => {
124
+ const A = styled("div")`color: red;`
125
+ const B = styled("span")`color: blue;`
126
+
127
+ const vnodeA = A({}) as VNode
128
+ const vnodeB = B({}) as VNode
129
+
130
+ expect(vnodeA.type).toBe("div")
131
+ expect(vnodeB.type).toBe("span")
132
+ })
133
+ })
134
+
135
+ describe("createGlobalStyle", () => {
136
+ it("static global style returns null", () => {
137
+ const GlobalStyle = createGlobalStyle`body { margin: 0; }`
138
+ const result = GlobalStyle({})
139
+
140
+ expect(result).toBeNull()
141
+ })
142
+ })
143
+ })
144
+
145
+ describe("hybrid injection — boost option at component level", () => {
146
+ afterEach(() => {
147
+ sheet.clearAll()
148
+ })
149
+
150
+ it("static boosted component produces doubled selector in CSSOM", () => {
151
+ const Comp = styled("div", { boost: true })`color: red;`
152
+ const vnode = Comp({}) as VNode
153
+ const className = vnode.props.class as string
154
+
155
+ const rules = findRulesFor(className)
156
+ expect(rules.length).toBeGreaterThanOrEqual(1)
157
+ // Boost doubles the selector: .pyr-abc.pyr-abc
158
+ expect(rules.some((r) => r.includes(`.${className}.${className}`))).toBe(true)
159
+ })
160
+
161
+ it("dynamic boosted component produces doubled selector in CSSOM", () => {
162
+ const Comp = styled("div", { boost: true })`
163
+ color: ${(p: any) => p.$color};
164
+ `
165
+ const vnode = Comp({ $color: "blue" }) as VNode
166
+ const className = vnode.props.class as string
167
+
168
+ const rules = findRulesFor(className)
169
+ expect(rules.length).toBeGreaterThanOrEqual(1)
170
+ expect(rules.some((r) => r.includes(`.${className}.${className}`))).toBe(true)
171
+ })
172
+
173
+ it("non-boosted component produces single selector", () => {
174
+ const Comp = styled("div")`color: green;`
175
+ const vnode = Comp({}) as VNode
176
+ const className = vnode.props.class as string
177
+
178
+ const rules = findRulesFor(className)
179
+ expect(rules.length).toBeGreaterThanOrEqual(1)
180
+ // Single selector: .pyr-abc { ... } — NOT .pyr-abc.pyr-abc
181
+ const baseRule = rules[0] as string
182
+ expect(baseRule).toContain(`.${className}`)
183
+ // Count occurrences of the className in the selector portion
184
+ const selectorPart = baseRule.split("{")[0] as string
185
+ const occurrences = selectorPart.split(`.${className}`).length - 1
186
+ expect(occurrences).toBe(1)
187
+ })
188
+
189
+ it("boosted component with @media splits correctly", () => {
190
+ const Comp = styled("div", { boost: true })`
191
+ color: red;
192
+ @media (min-width: 768px) { font-size: 20px; }
193
+ `
194
+ const vnode = Comp({}) as VNode
195
+ const className = vnode.props.class as string
196
+
197
+ const rules = findRulesFor(className)
198
+ // Should have at least 2 rules: base + @media
199
+ expect(rules.length).toBeGreaterThanOrEqual(2)
200
+ // Both base and media rule should use doubled selector
201
+ for (const rule of rules) {
202
+ expect(rule).toContain(`.${className}.${className}`)
203
+ }
204
+ })
205
+ })
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Test barrel / helper file.
3
+ * Re-exports commonly used test utilities from the styler source.
4
+ */
5
+ export { css } from "../css"
6
+ export { createGlobalStyle } from "../globalStyle"
7
+ export { HASH_INIT, hash, hashFinalize, hashUpdate } from "../hash"
8
+ export { keyframes } from "../keyframes"
9
+ export type { CSSResult, Interpolation } from "../resolve"
10
+ export { clearNormCache, normalizeCSS, resolve, resolveValue } from "../resolve"
11
+ export type { StyleSheetOptions } from "../sheet"
12
+ export { createSheet, StyleSheet, sheet } from "../sheet"
13
+ export { styled } from "../styled"
14
+ export { ThemeContext, ThemeProvider, useTheme } from "../ThemeProvider"