Skip to content

Commit 266de32

Browse files
committed
fix(tailwind): restore v3 hover, blur and breakpoint behaviour
An independent parity audit across light, dark and touch contexts found four user-visible differences the earlier desktop-only checks could not see. - hover: v4 wraps `hover:`/`group-hover:`/`peer-hover:` in `@media (hover: hover)`, so 64 hover-family classes were dead on touch devices — row highlights, reveal-on-hover icons, nav menus. This codebase already has `hover-hover` for the cases that want that gating, so plain `hover:` was meant to apply everywhere. Restored with `@custom-variant hover`. - blur-0 no longer exists in v4. Two sites used `group-hover:blur-0` to clear a base `blur-[2px]`, leaving the icon permanently blurred. Renamed to `blur-none`. - space-y-* dropped from specificity 0-3-0 to `:where()` 0-0-0 and moved to the preceding sibling, so a child's own `mt-*` now wins. Two layouts grew 4-8px; removed the child margins v3 was overriding. - Breakpoints: v4 emits rem, which resolves against the browser's initial font size rather than `html { font-size }`, so a reader on a larger default text size got the mobile layout at desktop widths. Pinned back to v3's px. Hardening from the same pass: - `::before`/`::after`/`::backdrop` border-color — v4 leaves them `currentColor` and the app's `*` rule did not cover pseudo-elements. - apps/docs was missing the `cn-tables.ts` source exclusion, so its bundle carried phantom `float-left`, `clear-end` and every `mix-blend-*`. - Hairline plugin: narrowed to border-width longhands (it could rewrite an `outline-width` sharing a rule), stopped `\b1px\b` splicing into `0.1px`, and skipped node_modules stylesheets. Added 18 fixture tests. - 23 golden-case tests for `cn`, pinning the font-size class group. - The tables drift check now derives its flags from `cn:build` so the two cannot diverge, uses the local bin instead of `bun x`, and writes to tmpdir.
1 parent aba7b8b commit 266de32

15 files changed

Lines changed: 333 additions & 39 deletions

File tree

.gitignore

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,3 @@ __pycache__/
107107
# `apps/sim/lib/uploads/` — 61 files of tracked source — and silently ignore
108108
# anything added there later.
109109
/uploads
110-
111-
# Scratch dir for `check:cn-tables` (regenerates the cn lookup tables to a
112-
# throwaway path and diffs them against the committed copy). Anchored to the one
113-
# directory the script actually creates, so an unrelated `.cn-check` elsewhere
114-
# is not silently hidden.
115-
/packages/emcn/.cn-check/

apps/docs/app/global.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@
55
@source "../../../packages/emcn/src";
66
@source "../../../packages/workflow-renderer/src";
77

8+
/* emcn's compiled `cn` lookup tables encode Tailwind's own class-group literals
9+
as string data. The scanner cannot tell those from real class usage, so
10+
leaving the file in scope emits utilities nothing renders (`float-left`,
11+
`clear-end`, every `mix-blend-*`, …). apps/sim/app/_styles/globals.css
12+
carries the same exclusion — both apps scan packages/emcn/src. */
13+
@source not "../../../packages/emcn/src/lib/cn-tables.ts";
14+
815
/* Every @sim/emcn component expresses hover through `hover-hover:` so touch
916
devices never latch a sticky hover state. The app registers it as a plugin
1017
variant in apps/sim/app/_styles/globals.css; docs is CSS-first Tailwind v4 with no

apps/sim/app/(landing)/components/content-post-page/content-post-loading.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function ContentPostLoading() {
4040
<Skeleton className='h-[16px] w-[95%] rounded-[4px] bg-[var(--surface-hover)]' />
4141
<Skeleton className='h-[16px] w-[88%] rounded-[4px] bg-[var(--surface-hover)]' />
4242
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
43-
<Skeleton className='mt-6 h-[24px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
43+
<Skeleton className='h-[24px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
4444
<Skeleton className='h-[16px] w-full rounded-[4px] bg-[var(--surface-hover)]' />
4545
<Skeleton className='h-[16px] w-[92%] rounded-[4px] bg-[var(--surface-hover)]' />
4646
<Skeleton className='h-[16px] w-[85%] rounded-[4px] bg-[var(--surface-hover)]' />

apps/sim/app/(landing)/components/hero/components/hero-visual/stage-home.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ interface StageHomeProps {
101101
const ENTER_BASE =
102102
'transition-[opacity,transform,filter] [transition-timing-function:cubic-bezier(0.2,0,0,1)]'
103103
const enterState = (shown: boolean) =>
104-
shown ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-1.5 opacity-0 blur-[3px]'
104+
shown ? 'translate-y-0 opacity-100 blur-none' : 'translate-y-1.5 opacity-0 blur-[3px]'
105105

106106
const Caret = () => (
107107
<span

apps/sim/app/_styles/globals.css

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@
4242
*/
4343
@custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
4444

45+
/**
46+
* v4 wraps `hover:` (and the `group-hover:`/`peer-hover:` variants derived from
47+
* it) in `@media (hover: hover)`, so hover styling goes dead on touch devices.
48+
* v3 emitted them unconditionally, and this codebase already has `hover-hover`
49+
* below for the cases that genuinely want that gating — so plain `hover:` was
50+
* deliberately meant to apply everywhere. Restored to the v3 semantics.
51+
*/
52+
@custom-variant hover (&:hover);
53+
4554
@custom-variant hover-hover {
4655
@media (hover: hover) and (pointer: fine) {
4756
&:hover {
@@ -77,6 +86,18 @@
7786
var(--font-martian-mono, ui-monospace), SFMono-Regular, Menlo, Monaco, Consolas,
7887
"Liberation Mono", "Courier New", monospace;
7988

89+
/**
90+
* Pinned in px. v4's defaults are rem, and rem in a media query resolves
91+
* against the browser's initial font size rather than `html { font-size }` —
92+
* so a reader who raised their browser's default text size would get the
93+
* mobile layout at a desktop width. These are v3's px values unchanged.
94+
*/
95+
--breakpoint-sm: 640px;
96+
--breakpoint-md: 768px;
97+
--breakpoint-lg: 1024px;
98+
--breakpoint-xl: 1280px;
99+
--breakpoint-2xl: 1536px;
100+
80101
--text-micro: 10px;
81102
/**
82103
* `xs` is the one size here that also exists in Tailwind's own scale, so it
@@ -1034,8 +1055,20 @@ html.sidebar-booting .sidebar-shell-inner {
10341055
* Base styles for body, scrollbars, and global elements
10351056
*/
10361057
@layer base {
1037-
* {
1058+
/**
1059+
* The pseudo-elements are listed because v4's Preflight no longer defaults
1060+
* their border-color the way v3's did — it leaves them at `currentColor`, so
1061+
* the first `before:border-*` anyone writes would draw a near-black hairline
1062+
* instead of the neutral token.
1063+
*/
1064+
*,
1065+
::before,
1066+
::after,
1067+
::backdrop {
10381068
border-color: var(--border);
1069+
}
1070+
1071+
* {
10391072
overscroll-behavior-x: none;
10401073
}
10411074

apps/sim/app/unsubscribe/unsubscribe.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ function UnsubscribeContent() {
124124
<p className={'text-[var(--text-muted)] text-md'}>
125125
Choose which emails you'd like to stop receiving.
126126
</p>
127-
<p className={'mt-2 text-[var(--text-muted)] text-sm'}>{data?.email}</p>
127+
<p className={'text-[var(--text-muted)] text-sm'}>{data?.email}</p>
128128
</div>
129129

130130
<div className={'mt-8 w-full max-w-[410px] space-y-3'}>

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -540,8 +540,8 @@ function BreadcrumbLocationPopover({
540540
)}
541541
>
542542
<span className='relative inline-grid size-[16px] shrink-0 place-items-center'>
543-
<Icon className='col-start-1 row-start-1 size-[16px] text-[var(--text-icon)] opacity-100 blur-0 transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
544-
<ArrowUpLeft className='col-start-1 row-start-1 size-[16px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-0 motion-reduce:transition-none' />
543+
<Icon className='col-start-1 row-start-1 size-[16px] text-[var(--text-icon)] opacity-100 blur-none transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
544+
<ArrowUpLeft className='col-start-1 row-start-1 size-[16px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-none group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-none motion-reduce:transition-none' />
545545
</span>
546546
{rootBreadcrumb?.label && (
547547
<OverflowText

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,15 @@ export const MicButton = memo(function MicButton({
102102
<span
103103
className={cn(
104104
'absolute inset-0 flex items-center justify-center transition-[opacity,filter,scale] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
105-
isListening ? 'scale-100 opacity-100 blur-0' : 'scale-[0.25] opacity-0 blur-[4px]'
105+
isListening ? 'scale-100 opacity-100 blur-none' : 'scale-[0.25] opacity-0 blur-[4px]'
106106
)}
107107
>
108108
<VoiceWaveform audioLevelsRef={audioLevelsRef} isListening={isListening} />
109109
</span>
110110
<Mic
111111
className={cn(
112112
'size-[16px] transition-[opacity,filter,scale] duration-300 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
113-
isListening ? 'scale-[0.25] opacity-0 blur-[4px]' : 'scale-100 opacity-100 blur-0'
113+
isListening ? 'scale-[0.25] opacity-0 blur-[4px]' : 'scale-100 opacity-100 blur-none'
114114
)}
115115
/>
116116
</Button>

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -516,8 +516,8 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
516516
className='-mx-1.5 -my-0.5 group flex w-fit min-w-0 max-w-[calc(100%+0.75rem)] items-center gap-1.5 rounded-[5px] px-1.5 py-0.5 transition-colors hover-hover:bg-[var(--surface-active)] focus-visible:bg-[var(--surface-active)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--text-muted)_30%,transparent)]'
517517
>
518518
<span className='inline-grid size-[14px] shrink-0 place-items-center'>
519-
<Workflow className='col-start-1 row-start-1 size-[14px] text-[var(--text-icon)] opacity-100 blur-0 transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
520-
<SquareArrowUpRight className='col-start-1 row-start-1 size-[14px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-0 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-0 motion-reduce:transition-none' />
519+
<Workflow className='col-start-1 row-start-1 size-[14px] text-[var(--text-icon)] opacity-100 blur-none transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-[0.25] group-hover:opacity-0 group-hover:blur-[2px] group-focus-visible:scale-[0.25] group-focus-visible:opacity-0 group-focus-visible:blur-[2px] motion-reduce:transition-none' />
520+
<SquareArrowUpRight className='col-start-1 row-start-1 size-[14px] scale-[0.25] text-[var(--text-icon)] opacity-0 blur-[2px] transition-[opacity,filter,transform] duration-200 ease-in-out group-hover:scale-100 group-hover:opacity-100 group-hover:blur-none group-focus-visible:scale-100 group-focus-visible:opacity-100 group-focus-visible:blur-none motion-reduce:transition-none' />
521521
</span>
522522
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm transition-colors group-hover:text-[var(--text-primary)] group-focus-visible:text-[var(--text-primary)]'>
523523
{workflowLabel}

apps/sim/lib/postcss/hairline-border-width.mjs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,31 @@ const HAIRLINE_UTILITIES = new Set([
3232
'divide-y',
3333
])
3434

35-
const WIDTH_PROPERTY = /(^|-)width$/
35+
/**
36+
* Only border-width longhands. A looser `/-width$/` would also catch
37+
* `outline-width`, `stroke-width`, `column-rule-width` and friends if one ever
38+
* shared a rule with a border utility — reachable through `@apply`.
39+
*/
40+
const BORDER_WIDTH_PROPERTIES = new Set([
41+
'border-width',
42+
'border-top-width',
43+
'border-right-width',
44+
'border-bottom-width',
45+
'border-left-width',
46+
'border-inline-width',
47+
'border-inline-start-width',
48+
'border-inline-end-width',
49+
'border-block-width',
50+
'border-block-start-width',
51+
'border-block-end-width',
52+
])
53+
54+
/**
55+
* Bare `1px` only. `\b1px\b` would also match the `1px` inside `0.1px` and
56+
* splice the variable into the middle of the number, producing CSS that is
57+
* silently invalid rather than failing the build.
58+
*/
59+
const ONE_PIXEL = /(?<![\w.])1px\b/g
3660

3761
/**
3862
* Every class name a selector references, unescaped. The escape alternative
@@ -62,14 +86,19 @@ function isHairlineRule(rule) {
6286

6387
const plugin = () => ({
6488
postcssPlugin: 'sim-hairline-border-width',
65-
OnceExit(root) {
89+
OnceExit(root, { result }) {
90+
// Third-party stylesheets travel through the same pipeline. Several are
91+
// themselves Tailwind output, so a dependency bump could ship a `.border`
92+
// rule that has no idea what `--border-width` is.
93+
if (result.opts.from?.includes('node_modules')) return
94+
6695
root.walkRules((rule) => {
6796
if (!isHairlineRule(rule)) return
6897
rule.walkDecls((decl) => {
69-
if (!WIDTH_PROPERTY.test(decl.prop)) return
70-
// `1px` only — an arbitrary `border-[2px]` keeps its own value, and the
98+
if (!BORDER_WIDTH_PROPERTIES.has(decl.prop)) return
99+
// An arbitrary `border-[2px]` keeps its own value; the divide utilities'
71100
// `calc(1px * var(--tw-divide-*-reverse))` form is rewritten in place.
72-
decl.value = decl.value.replace(/\b1px\b/g, 'var(--border-width)')
101+
decl.value = decl.value.replace(ONE_PIXEL, 'var(--border-width)')
73102
})
74103
})
75104
},

0 commit comments

Comments
 (0)