diff --git a/apps/web/src/app/__tests__/touch-reveal-rules.test.ts b/apps/web/src/app/__tests__/touch-reveal-rules.test.ts new file mode 100644 index 0000000000..b65330dcde --- /dev/null +++ b/apps/web/src/app/__tests__/touch-reveal-rules.test.ts @@ -0,0 +1,176 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; + +/** + * The touch-reveal rules in globals.css are blunt attribute-substring selectors. + * Everything else about this feature is unit-tested, but until now the selectors + * themselves were only ever verified by reading the compiled CSS — nothing failed + * if they stopped matching the controls they exist to reveal, or started matching + * something they must not touch. + * + * This reads the SELECTORS OUT OF globals.css (rather than restating them, which + * would only test a copy of itself) and runs them against the real class strings + * from the components, so the CSS and the markup are pinned to each other. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const GLOBALS_CSS = resolve(here, '../globals.css'); + +/** Selectors of every unlayered rule keyed on the coarse-pointer stamp. */ +let revealSelector: string; +let displaySelectors: string[]; + +beforeAll(() => { + const css = readFileSync(GLOBALS_CSS, 'utf8'); + + // Rule bodies are `:where([data-pointer='coarse']) { ... }`. Capture + // the FULL selector including the `:where()` ancestor — dropping it would leave + // the desktop assertion below testing nothing, since every rule would match + // regardless of whether carries the stamp. + const rules = [...css.matchAll(/(:where\(\[data-pointer='coarse'\]\)[^{]+)\{([^}]+)\}/g)].map( + (m) => ({ selector: m[1].replace(/\s+/g, ' ').trim(), body: m[2] }), + ); + + const reveal = rules.filter((r) => r.body.includes('opacity: 1')); + expect(reveal, 'globals.css should contain exactly one opacity reveal rule').toHaveLength(1); + revealSelector = reveal[0].selector; + + displaySelectors = rules.filter((r) => r.body.includes('display:')).map((r) => r.selector); + expect(displaySelectors.length, 'globals.css should contain display reveal rules').toBeGreaterThan(0); +}); + +/** Does an element with this class list get revealed on a coarse-pointer device? */ +function isRevealed(className: string, attrs: Record = {}): boolean { + document.documentElement.setAttribute('data-pointer', 'coarse'); + const el = document.createElement('div'); + el.className = className; + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + document.body.appendChild(el); + return document.querySelectorAll(revealSelector).length === 1; +} + +function isDisplayRevealed(className: string, attrs: Record = {}): boolean { + document.documentElement.setAttribute('data-pointer', 'coarse'); + const el = document.createElement('div'); + el.className = className; + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + document.body.appendChild(el); + return displaySelectors.some((s) => document.querySelectorAll(s).length === 1); +} + +afterEach(() => { + document.body.innerHTML = ''; + document.documentElement.removeAttribute('data-pointer'); +}); + +describe('touch reveal rules — controls that MUST become visible', () => { + // Class strings copied verbatim from the components they belong to. + const REVEALED: Array<[string, string]> = [ + [ + 'MessageActionButtons — AI chat retry/edit/copy/delete (the headline bug)', + 'flex items-center space-x-1 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity', + ], + [ + 'ui/sidebar SidebarMenuAction — page-tree row actions (md: viewport gate)', + 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0', + ], + [ + 'TerminalPanes — the md: gate master re-introduced in #2006', + 'absolute right-1.5 top-1.5 z-10 flex items-center opacity-100 transition-opacity focus-within:opacity-100 md:opacity-0 md:group-hover/pane:opacity-100', + ], + [ + 'MessageHoverToolbar — named group + pointer-events-none', + 'opacity-0 group-hover/msg:opacity-100 focus-within:opacity-100 pointer-events-none group-hover/msg:pointer-events-auto', + ], + [ + 'TabItem close button', + 'ml-1 rounded-sm p-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 focus:opacity-100', + ], + [ + 'prompt-input remove-attachment button', + 'absolute inset-0 size-5 rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100', + ], + [ + 'SidebarActivityTab sub-item — sm: gate + named group', + 'h-5 w-5 sm:opacity-0 sm:group-hover/item:opacity-100 transition-opacity flex-shrink-0', + ], + ]; + + it.each(REVEALED)('reveals %s', (_name, className) => { + expect(isRevealed(className)).toBe(true); + }); + + it('does nothing at all on a desktop device (no data-pointer stamp)', () => { + const el = document.createElement('div'); + el.className = 'flex items-center sm:opacity-0 sm:group-hover:opacity-100'; + document.body.appendChild(el); + // No data-pointer on — the ancestor condition fails, so no rule can match. + expect(document.querySelectorAll(revealSelector)).toHaveLength(0); + }); +}); + +describe('touch reveal rules — decoration that MUST STAY hidden', () => { + const OPTED_OUT: Array<[string, string]> = [ + [ + 'FeedbackDialog screenshot scrim (would black out the preview)', + 'absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity', + ], + [ + 'resizable drag seam', + 'h-full w-px bg-sidebar-border transition-opacity group-hover:opacity-100 group-data-[separator=active]:opacity-100 opacity-0', + ], + [ + 'ChannelView message timestamp', + 'absolute inset-y-0 right-2 flex items-center opacity-0 group-hover/msg:opacity-100 transition-opacity tabular-nums', + ], + [ + 'StatusConfigManager slug hint', + 'text-xs text-muted-foreground ml-auto opacity-0 group-hover:opacity-100', + ], + ]; + + it.each(OPTED_OUT)('keeps %s hidden via data-hover-only', (_name, className) => { + expect(isRevealed(className, { 'data-hover-only': '' })).toBe(false); + // ...and would otherwise have been revealed, so the opt-out is load-bearing. + expect(isRevealed(className)).toBe(true); + }); + + it('never pins a hover FADE-OUT visible — it would cover the content beneath it', () => { + // prompt-input.tsx: the attachment thumbnail that fades out to expose its + // remove button. Forcing opacity:1 here would be an inversion. + expect( + isRevealed('absolute inset-0 flex size-5 rounded bg-background transition-opacity group-hover:opacity-0'), + ).toBe(false); + }); + + it('still excludes a fade-out that later grows an :opacity-100 variant (the landmine)', () => { + // This is the case the `:not([class*='group-hover:opacity-0'])` guard exists for. + // Without it, adding focus-within:opacity-100 to a scrim pins the scrim VISIBLE. + expect( + isRevealed('absolute inset-0 bg-black/50 transition-opacity group-hover:opacity-0 focus-within:opacity-100'), + ).toBe(false); + }); +}); + +describe('touch reveal rules — display-based reveals', () => { + it('reveals a hidden group-hover:flex control', () => { + expect(isDisplayRevealed('hidden group-hover:flex items-center gap-1')).toBe(true); + }); + + it('keeps TabItem’s Cmd+N shortcut hint hidden — it is a keyboard affordance', () => { + expect( + isDisplayRevealed('text-[10px] text-white/70 flex-shrink-0 hidden group-hover:inline', { + 'data-hover-only': '', + }), + ).toBe(false); + }); + + it('does not let group-hover:flex-row masquerade as a display reveal', () => { + // Exact-token (`~=`) matching, not substring: a flex-DIRECTION change on hover + // must not be forced to `display: flex` on touch. + expect(isDisplayRevealed('flex group-hover:flex-row items-center')).toBe(false); + }); +}); diff --git a/apps/web/src/app/dashboard/dms/[conversationId]/page.tsx b/apps/web/src/app/dashboard/dms/[conversationId]/page.tsx index 59d1caf944..fc03c63d81 100644 --- a/apps/web/src/app/dashboard/dms/[conversationId]/page.tsx +++ b/apps/web/src/app/dashboard/dms/[conversationId]/page.tsx @@ -621,13 +621,13 @@ export default function InboxDMPage() { ) : (
- + {new Date(message.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
)} -
+
{isFirst && (
{senderName} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index be87e9dc5c..cd9abe1d71 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -7,10 +7,86 @@ @custom-variant dark (&:is(.dark *)); +/* Touch devices are stamped with data-pointer="coarse" on pre-paint by + the inline script in layout.tsx (see lib/pointer-capability.ts). Use `touch:` + for any new code that needs a touch-only rule. */ +@custom-variant touch (&:where([data-pointer='coarse'] *)); + svg.lucide { stroke-width: 1.5; } +/* --------------------------------------------------------------------------- + Hover-revealed controls on touch devices. + + There is no hover on a touchscreen, so every `opacity-0 group-hover:opacity-100` + affordance (message retry/edit/copy, page-tree row actions, tab close buttons, + version-history and activity actions, …) is unreachable. Worse, the `sm:`/`md:` + -prefixed variants consult no pointer signal at all, so they break in iPad + Safari too — an iPad is wide enough to match `sm:`, but never hovers. + + These rules are UNLAYERED (Tailwind utilities live in `@layer utilities`), so + they win without `!important` — same trick as `svg.lucide` above. + + Attribute-substring matching is required: a plain `.group-hover\:opacity-100` + selector would miss both the named-group variants (`group-hover/msg:`, + `group-hover/pane:`, `group-hover/item:`, `group-hover/menu-item:`) and the + `sm:`/`md:`-prefixed forms. + + `data-hover-only` opts an element out — for decoration (scrims, drag seams, + timestamps) as opposed to affordances. + --------------------------------------------------------------------------- */ + +/* Reveal pair: a resting `opacity-0` that a group-hover restores to full. + `pointer-events: auto` is required — MessageHoverToolbar and prompt-input pair + their `opacity-0` with `pointer-events-none`, so opacity alone would leave the + buttons visible but dead. + + The `group-hover:opacity-0` exclusion matters: an element that fades OUT on + hover (to expose a control beneath it) also carries the `opacity-0` substring. + Without the guard, adding any `:opacity-100` variant to such an element — a + `focus-within:opacity-100`, say — would drag it into this rule and pin the + fade-out permanently VISIBLE, hiding whatever it covers. We reveal controls; + we do not simulate a whole hover state. Elements that fade out are simply left + alone, at their natural resting opacity. */ +:where([data-pointer='coarse']) + [class*='opacity-0'][class*='group-hover'][class*=':opacity-100']:not([class*='group-hover:opacity-0']):not([data-hover-only]) { + opacity: 1; + pointer-events: auto; +} + +/* Display-based reveals (`hidden group-hover:flex`), not opacity-based. + + These have ZERO live matches today, and that is deliberate rather than an + oversight: the repo's only `hidden group-hover:inline` is TabItem's Cmd+N + shortcut hint, which is a keyboard affordance and is opted out. The rules stay + as the standing policy, so the next display-based control — `hidden + group-hover:flex` is the idiomatic way to hide a button row — is revealed on + touch instead of silently disappearing, which is the entire bug class this file + exists to close. + + These match the exact class token (`~=`), NOT a substring. Substring matching + is correct for the opacity rules above — it is what catches `sm:` and + named-group variants — but here it would make `group-hover:flex` also match + `group-hover:flex-row` and force `display: flex` on an element that only asked + to change its flex-direction. For a display reveal there is nothing to catch: + the token is written out in full. */ +:where([data-pointer='coarse']) [class~='group-hover:inline']:not([data-hover-only]) { + display: inline; +} +:where([data-pointer='coarse']) [class~='group-hover:inline-flex']:not([data-hover-only]) { + display: inline-flex; +} +:where([data-pointer='coarse']) [class~='group-hover:flex']:not([data-hover-only]) { + display: flex; +} +:where([data-pointer='coarse']) [class~='group-hover:block']:not([data-hover-only]) { + display: block; +} +:where([data-pointer='coarse']) [class~='group-hover:grid']:not([data-hover-only]) { + display: grid; +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 4320ddb808..58bfdf6794 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -10,6 +10,7 @@ import ClientTrackingProvider from "@/components/providers/ClientTrackingProvide import ConsentProvider from "@/components/providers/ConsentProvider"; import { Toaster } from "@/components/ui/sonner"; import { getRequestNonce } from "@/lib/request-nonce"; +import { POINTER_CAPABILITY_SCRIPT } from "@/lib/pointer-capability"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -99,6 +100,15 @@ export default async function RootLayout({ + {/* Stamp data-pointer="coarse" on before first paint so the + unlayered touch rules in globals.css apply without a flash of hidden + controls. Emitted only client-side: the server renders no attribute, + so desktop SSR output is unchanged. carries + suppressHydrationWarning, so the pre-paint mutation is free. */} +