Skip to content

Commit e23acb6

Browse files
merge: update PR #707 with main and preserve inherited captures
Refs #688 #707 #776 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2 parents c7ea90a + b621716 commit e23acb6

25 files changed

Lines changed: 3029 additions & 607 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"changes": {
3+
"bindings/devup-ui-wasm/package.json": "Patch",
4+
"apps/landing/package.json": "Patch"
5+
},
6+
"note": "Respect source-order property replacement across conditional spreads in styled rule objects, and document that styled callbacks cannot return runtime class names. No new build errors; existing located Cannot compose errors remain unchanged.",
7+
"date": "2026-10-01T00:00:00.000Z"
8+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"changes": {
3+
"bindings/devup-ui-wasm/package.json": "Patch"
4+
},
5+
"note": "Order standalone nonresponsive hover-capability guards within their selector group so active, focus and disabled styles can override hover. Keep responsive Tailwind hover and Devup selector prop ordering unchanged. No new build errors.",
6+
"date": "2026-10-09T00:00:00.000Z"
7+
}

‎apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ A style argument can be conditional — `cond && { ... }`, `cond ? { ... } : { .
4040

4141
A style argument that cannot be known at build time — a function call the build cannot run, a spread or a computed key it cannot compute — is a build error naming it, rather than a silently missing style; a spread, a selector value or a computed key the module computes (`styled.div({ ...cardStyles(), _hover: hoverStyles })`) is computed, while the other values stay CSS variables set at runtime. An object constant, declared in the file or imported, reads as if written in place: `styled.div(baseStyles)`, `styled.div({ ...baseStyles, color: 'red' })`. A single argument that is not a rule object composes as a class, as several do; one holding rules the module computes (`const base = makeStyles()`, `styled.div(base)`) applies those rules. In the object form a value may read the theme (`(p) => p.theme.brand`); any other function of the props is a build error — write it in a template (`${(p) => p.color}`), where it becomes a CSS variable.
4242

43+
A styled callback cannot return a class name known only at runtime (`styled.div(p => { return p.cls; })`): the build reports a located `Cannot compose` error. A condition choosing between known classes or rule objects is supported.
44+
4345
Inside a template, an interpolation standing where a declaration would is a mixin: `${mixin}` and `${(p) => p.active && mixin}` compose their class with the component's. A literal or constant is written into the CSS wherever it stands — a value, a CSS snippet, a selector. Any other interpolation in a selector or a property name is a build error, as are runtime values in `css`, `keyframes` and global styles, which have no element to set a CSS variable on.
4446

4547
`.attrs()` and `.withConfig()` chain onto any factory. Attrs are merged over the props in order, as styled-components does — an object is spread, a function is called with the props; `.withConfig()` only tunes runtime behavior, so it is dropped:

‎e2e/tailwind-hover-order.spec.ts‎

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { readFileSync } from 'node:fs'
2+
import { createRequire } from 'node:module'
3+
4+
import { expect, test } from '@playwright/test'
5+
6+
import {
7+
codeExtractWithoutSourceMap,
8+
getCss,
9+
importClassMap,
10+
importFileMap,
11+
importSheet,
12+
registerTheme,
13+
} from '../bindings/devup-ui-wasm/pkg/index.js'
14+
15+
const tailwindRequire = createRequire(
16+
new URL('../benchmark/next-tailwind/package.json', import.meta.url),
17+
)
18+
const {
19+
compile,
20+
}: typeof import('../benchmark/next-tailwind/node_modules/tailwindcss/dist/lib.mjs') =
21+
tailwindRequire('tailwindcss')
22+
const tailwindTheme = readFileSync(
23+
tailwindRequire.resolve('tailwindcss/theme.css'),
24+
'utf8',
25+
)
26+
const cases = [
27+
{
28+
classes: 'hover:bg-red-500 active:bg-blue-500',
29+
winners: ['blue', 'blue'],
30+
},
31+
{
32+
classes: 'md:hover:bg-red-500 active:bg-blue-500',
33+
winners: ['blue', 'red'],
34+
},
35+
{
36+
classes: 'hover:bg-red-500 focus:bg-green-500 active:bg-blue-500',
37+
winners: ['blue', 'blue'],
38+
},
39+
{
40+
classes: 'hover:bg-red-500 disabled:bg-gray-500',
41+
winners: ['gray', 'gray'],
42+
},
43+
{
44+
classes: 'dark:hover:bg-red-500 active:bg-blue-500',
45+
winners: ['red', 'red'],
46+
},
47+
{
48+
classes: 'hover:bg-red-500 active:bg-blue-500',
49+
props:
50+
'_media={{"(hover: hover)": {_hover: {bgColor:"#EF4444"}}}} _active={{bgColor:"#3B82F6"}}',
51+
winners: ['blue', 'blue'],
52+
},
53+
] as const
54+
const colors = {
55+
red: ['oklch(0.637 0.237 25.331)', 'rgb(239, 68, 68)'],
56+
blue: ['oklch(0.623 0.214 259.815)', 'rgb(59, 130, 246)'],
57+
green: ['oklch(0.723 0.219 149.579)', 'rgb(34, 197, 94)'],
58+
gray: ['oklch(0.551 0.027 264.364)', 'rgb(107, 114, 128)'],
59+
}
60+
61+
for (const singleCss of [true, false]) {
62+
for (const fixture of [
63+
{
64+
props:
65+
'_hover={{bg:"red"}} _focus={{bg:"green"}} _active={{bg:"blue"}} _disabled={{bg:"gray"}}',
66+
css: '.a:hover{background:red}.b:focus{background:green}.c:active{background:blue}.d:disabled{background:gray}',
67+
},
68+
{
69+
props:
70+
'bg={["white","black"]} _hover={{bg:["red",null,"orange"]}} _focus={{bg:"green"}} _active={{bg:"blue"}} _disabled={{bg:"gray"}}',
71+
css: '.a{background:white}@media(min-width:480px){.b{background:black}}.c:hover{background:red}@media(min-width:768px){.d:hover{background:orange}}.e:focus{background:green}.f:active{background:blue}.g:disabled{background:gray}',
72+
},
73+
]) {
74+
test(`Devup selector props unchanged, ${fixture.props}, singleCss=${singleCss}`, () => {
75+
importSheet({})
76+
importClassMap({})
77+
importFileMap({})
78+
registerTheme({})
79+
const output = codeExtractWithoutSourceMap(
80+
'selector-props.tsx',
81+
`import { Box } from '@devup-ui/react';\n<Box ${fixture.props} />`,
82+
'@devup-ui/react',
83+
'df',
84+
singleCss,
85+
false,
86+
false,
87+
{},
88+
)
89+
try {
90+
const actual = singleCss ? getCss(null, false) : output.css
91+
const expected = singleCss
92+
? fixture.css
93+
: fixture.css.replace(/\.([a-g])/g, '.a-$1')
94+
expect(actual?.replace(/^\/\*[\s\S]*?\*\//, '')).toBe(expected)
95+
} finally {
96+
output.free()
97+
}
98+
})
99+
}
100+
}
101+
102+
test.describe('Hover capability guard', () => {
103+
test.use({ hasTouch: true, isMobile: true })
104+
for (const singleCss of [true, false]) {
105+
test(`touch input keeps the base background, singleCss=${singleCss}`, async ({
106+
page,
107+
}) => {
108+
const classes = 'bg-white hover:bg-red-500'
109+
const native = await compile(`${tailwindTheme}\n@tailwind utilities;`)
110+
const oracleCss = native.build(classes.split(' '))
111+
importSheet({})
112+
registerTheme({})
113+
const output = codeExtractWithoutSourceMap(
114+
'hover-capability.tsx',
115+
`import { Box } from '@devup-ui/react';\n<Box className="${classes}" />`,
116+
'@devup-ui/react',
117+
'df',
118+
singleCss,
119+
false,
120+
false,
121+
{},
122+
)
123+
try {
124+
const matched = output.code.match(/className="([^"]+)"/)
125+
expect(matched).not.toBeNull()
126+
const devupCss = singleCss
127+
? getCss(null, false)
128+
: getCss(null, false) + output.css
129+
expect(devupCss).toContain('(hover:hover)')
130+
for (const [className, css] of [
131+
[classes, oracleCss],
132+
[matched?.[1], devupCss],
133+
]) {
134+
await page.setContent(
135+
`<!doctype html><style>${css}</style><div id="target" class="${className}" style="width:160px;height:80px">tap</div>`,
136+
)
137+
expect(
138+
await page.evaluate(() => matchMedia('(hover:hover)').matches),
139+
).toBe(false)
140+
await page.locator('#target').tap()
141+
await expect(page.locator('#target')).toHaveCSS(
142+
'background-color',
143+
'rgb(255, 255, 255)',
144+
)
145+
}
146+
} finally {
147+
output.free()
148+
}
149+
})
150+
}
151+
})
152+
153+
for (const singleCss of [true, false]) {
154+
for (const fixture of cases) {
155+
for (const [index, width] of [640, 1024].entries()) {
156+
const label = 'props' in fixture ? 'explicit Devup guard: ' : ''
157+
test(`${label}${fixture.classes}, ${width}px, singleCss=${singleCss}`, async ({
158+
page,
159+
}, testInfo) => {
160+
const disabled = fixture.classes.includes('disabled:')
161+
const native = await compile(`${tailwindTheme}\n@tailwind utilities;`)
162+
const oracleCss = native.build(fixture.classes.split(' '))
163+
importSheet({})
164+
registerTheme({})
165+
const props =
166+
'props' in fixture
167+
? fixture.props
168+
: `className="${fixture.classes}" ${disabled ? 'disabled' : ''}`
169+
const source = `import { Box } from '@devup-ui/react';\n<Box as="button" ${props} />`
170+
const output = codeExtractWithoutSourceMap(
171+
'hover-order.tsx',
172+
source,
173+
'@devup-ui/react',
174+
'df',
175+
singleCss,
176+
false,
177+
false,
178+
{},
179+
)
180+
try {
181+
const matched = output.code.match(/className="([^"]+)"/)
182+
expect(matched).not.toBeNull()
183+
if (!singleCss) expect(output.cssFile).toBeDefined()
184+
const devupCss = singleCss
185+
? getCss(null, false)
186+
: getCss(null, false) + output.css
187+
expect(devupCss).toContain('(hover:hover)')
188+
const expected = colors[fixture.winners[index]]
189+
await page.setViewportSize({ width, height: 400 })
190+
await page.emulateMedia({ colorScheme: 'dark' })
191+
const observed = []
192+
for (const [name, classes, css, color] of [
193+
['Tailwind', fixture.classes, oracleCss, expected[0]],
194+
['Devup', matched?.[1], devupCss, expected[1]],
195+
]) {
196+
await page.setContent(
197+
`<!doctype html><html data-theme="dark"><head><style>${css}</style></head><body><button id="target" class="${classes}" ${disabled ? 'disabled' : ''} style="width:160px;height:80px">probe</button></body></html>`,
198+
)
199+
await page.mouse.move(width - 1, 399)
200+
const target = page.locator('#target')
201+
const baseline = await target.evaluate(
202+
(element) => getComputedStyle(element).backgroundColor,
203+
)
204+
await target.hover()
205+
if (fixture.classes.startsWith('md:') && width < 768) {
206+
await expect(target).toHaveCSS('background-color', baseline)
207+
} else if (!disabled) {
208+
await expect(target).toHaveCSS(
209+
'background-color',
210+
colors.red[name === 'Tailwind' ? 0 : 1],
211+
)
212+
}
213+
if (fixture.classes.includes('focus:')) {
214+
await target.focus()
215+
await expect(target).toHaveCSS(
216+
'background-color',
217+
colors.green[name === 'Tailwind' ? 0 : 1],
218+
)
219+
}
220+
await page.mouse.down()
221+
try {
222+
const held = await target.evaluate((element) => ({
223+
color: getComputedStyle(element).backgroundColor,
224+
hover: element.matches(':hover'),
225+
active: element.matches(':active'),
226+
focus: element.matches(':focus'),
227+
disabled: element.matches(':disabled'),
228+
hoverMedia: matchMedia('(hover:hover)').matches,
229+
}))
230+
expect(held.hover, name).toBe(true)
231+
expect(held.active, name).toBe(true)
232+
expect(held.hoverMedia, name).toBe(true)
233+
expect(held.disabled, name).toBe(disabled)
234+
expect(held.focus, name).toBe(!disabled)
235+
observed.push({ name, held })
236+
expect(held.color, name).toBe(color)
237+
if (name === 'Devup') {
238+
const screenshot = testInfo.outputPath('held.png')
239+
await page.screenshot({ path: screenshot })
240+
await testInfo.attach('held-Devup', {
241+
path: screenshot,
242+
contentType: 'image/png',
243+
})
244+
}
245+
} finally {
246+
await page.mouse.up()
247+
}
248+
}
249+
await testInfo.attach('cascade-evidence', {
250+
body: JSON.stringify({ source, oracleCss, devupCss, observed }),
251+
contentType: 'application/json',
252+
})
253+
} finally {
254+
output.free()
255+
}
256+
})
257+
}
258+
}
259+
}

‎libs/extractor/src/extract_style/extract_dynamic_style.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ impl ExtractDynamicStyle {
137137
self.identifier.as_str()
138138
}
139139

140+
pub(crate) const fn identifier_mut(&mut self) -> &mut String {
141+
&mut self.identifier
142+
}
143+
140144
pub const fn style_order(&self) -> Option<u8> {
141145
self.style_order
142146
}

0 commit comments

Comments
 (0)