Skip to content
176 changes: 176 additions & 0 deletions apps/web/src/app/__tests__/touch-reveal-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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']) <selector> { ... }`. 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 <html> 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<string, string> = {}): 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<string, string> = {}): 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 <html> — 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);
});
});
4 changes: 2 additions & 2 deletions apps/web/src/app/dashboard/dms/[conversationId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -621,13 +621,13 @@ export default function InboxDMPage() {
</Avatar>
) : (
<div className="h-10 w-10 flex-shrink-0 relative" aria-hidden>
<span className="absolute inset-y-0 right-2 flex items-center text-[10px] text-muted-foreground opacity-0 group-hover/msg:opacity-100 transition-opacity tabular-nums">
<span data-hover-only="" className="absolute inset-y-0 right-2 flex items-center text-[10px] text-muted-foreground opacity-0 group-hover/msg:opacity-100 transition-opacity tabular-nums">
{new Date(message.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)}

<div className={cn("flex-1 min-w-0", !isFirst && "[@media(hover:none)]:pr-28")}>
<div className={cn("flex-1 min-w-0", !isFirst && "[@media(hover:none)]:pr-28 touch:pr-28")}>
{isFirst && (
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm">{senderName}</span>
Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,86 @@

@custom-variant dark (&:is(.dark *));

/* Touch devices are stamped with data-pointer="coarse" on <html> 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);
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -99,6 +100,15 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* Stamp data-pointer="coarse" on <html> 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. <html> carries
suppressHydrationWarning, so the pre-paint mutation is free. */}
<script
nonce={nonce}
dangerouslySetInnerHTML={{ __html: POINTER_CAPABILITY_SCRIPT }}
/>
{/* Set webpack nonce for dynamically loaded chunks (next/dynamic) */}
<script
nonce={nonce}
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/ai/ui/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,14 @@ export function PromptInputAttachment({
</div>
<Button
aria-label="Remove attachment"
className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
// On touch this button is pinned visible (globals.css) and there is no
// hover to fade the thumbnail out from under it, so the ✕ needs a
// backdrop or it is unreadable over a dark image. At a 20px chip there
// is no room for both: the backdrop largely occludes the thumbnail.
// That is the right trade — the filename beside it identifies the
// attachment, and a legible remove control matters more than a 20px
// preview.
className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 touch:bg-background/80 [&>svg]:size-2.5"
onClick={(e) => {
e.stopPropagation();
attachments.remove(data.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useCallback, type MouseEvent } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown, ExternalLink, Folder, MoreHorizontal, Star } from "lucide-react";
import { useTabsStore } from "@/stores/useTabsStore";
import { useTouchDevice } from "@/hooks/useTouchDevice";
import { shouldOpenInNewTab } from "@/lib/tabs/tab-navigation-utils";
import {
Collapsible,
Expand Down Expand Up @@ -148,6 +149,7 @@ interface FavoriteItemProps {

function FavoriteItem({ favorite, onNavigate, onOpenInNewTab, onRemove, hideTabActions }: FavoriteItemProps) {
const [isHovered, setIsHovered] = useState(false);
const isTouchDevice = useTouchDevice();

const title =
favorite.itemType === "drive"
Expand Down Expand Up @@ -202,7 +204,7 @@ function FavoriteItem({ favorite, onNavigate, onOpenInNewTab, onRemove, hideTabA
size="icon"
className={cn(
"absolute right-1 h-6 w-6 transition-opacity",
isHovered ? "opacity-100" : "opacity-0"
isTouchDevice || isHovered ? "opacity-100" : "opacity-0"
)}
onClick={(e) => e.stopPropagation()}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ export const PageTreeItem = React.memo(function PageTreeItem({
<div
className={cn(
"flex items-center ml-2 transition-opacity duration-200",
isHovered ? "opacity-100" : "opacity-0"
isTouchDevice || isHovered ? "opacity-100" : "opacity-0"
)}
>
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,12 +702,12 @@ function ChannelView({ page }: ChannelViewProps) {
</Avatar>
) : (
<div className="size-8 shrink-0 relative" aria-hidden>
<span className="absolute inset-y-0 right-2 flex items-center text-[10px] text-muted-foreground opacity-0 group-hover/msg:opacity-100 transition-opacity tabular-nums">
<span data-hover-only="" className="absolute inset-y-0 right-2 flex items-center text-[10px] text-muted-foreground opacity-0 group-hover/msg:opacity-100 transition-opacity tabular-nums">
{new Date(m.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)}
<div className={cn("flex flex-col min-w-0 flex-1", !isFirst && "[@media(hover:none)]:pr-28")}>
<div className={cn("flex flex-col min-w-0 flex-1", !isFirst && "[@media(hover:none)]:pr-28 touch:pr-28")}>
{isFirst && (
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{displayName}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ export function StatusConfigManager({
) : (
<>
<Badge className={cn('text-xs', config.color)}>{config.name}</Badge>
<span className="text-xs text-muted-foreground ml-auto opacity-0 group-hover:opacity-100">
{/* Metadata hint, not a control — keep it hover-only. */}
<span data-hover-only="" className="text-xs text-muted-foreground ml-auto opacity-0 group-hover:opacity-100">
{config.slug}
</span>
<DropdownMenu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,7 @@ export function ThreadPanel({
{author.image && <AvatarImage src={author.image} />}
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<div className={cn("min-w-0 flex-1", "[@media(hover:none)]:pr-28")}>
<div className={cn("min-w-0 flex-1", "[@media(hover:none)]:pr-28 touch:pr-28")}>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{author.name}</span>
<span className="text-xs text-muted-foreground">
Expand Down
Loading