fix(studio): meet the 24x24 pointer target minimum on toolbar and lane controls

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 16:44:42 +02:00
parent 69020699df
commit 4e74eefddd
10 changed files with 121 additions and 17 deletions
@@ -154,10 +154,10 @@ export function StudioLeftSidebar({
onAddAssetToTimeline={onAddAssetToTimeline}
onAddCompositionToTimeline={onAddCompositionToTimeline}
/>
{/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
{/* Vertical resize divider: 3px visible seam, 13px pointer-capture zone via
the absolutely-positioned inner hit area. The outer element is w-[3px] so
it contributes only 3px of gap in the flex row; the inner -left-[2.5px]
element widens the hit area to 8px without affecting layout. */}
it contributes only 3px of gap in the flex row; the inner -left-[2px]
element widens the hit area without affecting layout. */}
<div
role="separator"
aria-label="Resize sidebar"
@@ -176,8 +176,13 @@ export function StudioLeftSidebar({
adjustPanelWidth("left", delta);
}}
>
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
<div className="absolute inset-y-0 -left-[2.5px] w-2" />
{/* Expanded hit zone, deliberately asymmetric: 2px into the sidebar card,
the 3px seam, then 8px into the preview pane's p-2 stage gutter — the
only dead space adjacent to this seam. It stops at 13px rather than the
24px WCAG 2.2 (2.5.8) target because the next pixel on either side is
live: the sidebar's scrolling tab content on the left, the preview
stage on the right. Silently stealing their clicks is the worse bug. */}
<div className="absolute inset-y-0 -left-[2px] w-[13px]" />
{/* Visible hairline */}
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
@@ -446,7 +446,7 @@ export function StudioRightPanel({
return (
<>
{/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
{/* Vertical resize divider: 3px visible seam, 13px pointer-capture zone via
the absolutely-positioned inner hit area. */}
<div
role="separator"
@@ -467,8 +467,12 @@ export function StudioRightPanel({
adjustPanelWidth("right", delta);
}}
>
{/* Expanded hit zone: 8px wide, centered on the 3px seam */}
<div className="absolute inset-y-0 -left-[2.5px] w-2" />
{/* Expanded hit zone, deliberately asymmetric: 8px into the preview pane's
p-2 stage gutter (the only dead space here), the 3px seam, then 2px
into the inspector card. It stops at 13px rather than the 24px WCAG 2.2
(2.5.8) target because the next pixel on either side is live: the
preview stage on the left, the inspector's own controls on the right. */}
<div className="absolute inset-y-0 -left-[8px] w-[13px]" />
{/* Visible hairline */}
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
@@ -449,7 +449,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
setZoomMode("manual");
setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value)));
}}
className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
// h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
// track stays 2px and the thumb 10px, only the pointer box grows.
className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
/>
<Tooltip label="Zoom in">
<button
@@ -167,6 +167,11 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
};
return (
// The two 12x20 steppers sit gap-0.5 apart with a 9px diamond between them,
// so they stay below the 24px WCAG 2.2 (2.5.8) minimum under that criterion's
// own spacing/inline exception: centred 24px targets here would overlap each
// other AND the diamond, and one control swallowing its neighbour's clicks is
// a worse 2.5.8 failure than a small target. Do not "fix" these to 24.
<div className="flex h-5 items-center gap-0.5">
<button
type="button"
@@ -189,7 +189,9 @@ export function SliderControl({
onMouseUp={() => commitDraft(draft)}
onTouchEnd={() => commitDraft(draft)}
onBlur={() => commitDraft(draft)}
className="h-4 min-w-0 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-panel-border [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0C0C0E,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
// h-6 is the 24x24 WCAG 2.2 (2.5.8) target: the visible track stays 2px
// and the thumb 10px, only the pointer box grows.
className="h-6 min-w-0 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-panel-border [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0C0C0E,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
/>
<div className="min-w-[44px] rounded-md bg-panel-input px-2 py-1.5 text-right text-[11px] font-medium text-panel-text-1 tabular-nums">
{formatDisplayValue?.(draft) ?? displayValue}
@@ -73,9 +73,9 @@ export function TimelineResizeDivider({
);
return (
// Horizontal resize divider: 3px visible seam (h-[3px]), 8px pointer-capture
// Horizontal resize divider: 3px visible seam (h-[3px]), 10px pointer-capture
// zone via the absolutely-positioned inner hit area so the layout gap stays
// at 3px while draggability is preserved over the full 8px band.
// at 3px while draggability is preserved over the full band.
<div
role="separator"
aria-orientation="horizontal"
@@ -94,8 +94,13 @@ export function TimelineResizeDivider({
onPointerCancel={handlePointerUp}
onKeyDown={handleKeyDown}
>
{/* Expanded hit zone: 8px tall, centered on the 3px seam */}
<div className="absolute inset-x-0 -top-[2.5px] h-2" />
{/* Expanded hit zone, deliberately asymmetric: 4px up into the transport
row's vertical centring slack, the 3px seam, then 3px down into the
timeline card's border + toolbar padding. It stops at 10px rather than
the 24px WCAG 2.2 (2.5.8) target because that is all the dead space
there is: 28px transport buttons sit above and 28px toolbar buttons
below, and silently stealing their clicks is the worse bug. */}
<div className="absolute inset-x-0 -top-[4px] h-[10px]" />
{/* Visible hairline — invisible at rest, subtle wash on hover/drag/focus */}
<div className="h-[3px] w-full bg-transparent transition-colors group-hover:bg-white/12 group-active:bg-white/18 group-focus-visible:bg-studio-accent/60" />
</div>
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CompositionsTab } from "./sidebar/CompositionsTab";
import { TimelineToolbar } from "./TimelineToolbar";
// WCAG 2.2 (2.5.8) requires a 24x24 CSS pixel pointer target. happy-dom has no
// CSS engine, so getBoundingClientRect() is 0x0 for everything here and the size
// itself cannot be measured — these assert the utility classes that PRODUCE the
// size instead, which is enough to fail if someone drops them. Real geometry
// still needs a browser check.
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
(
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
).happyDOM.settings.disableIframePageLoading = true;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});
function mount(element: React.ReactNode) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => root?.render(element));
return host;
}
describe("pointer target size (WCAG 2.5.8)", () => {
it("gives the timeline zoom slider a 24px box without changing its 2px track or 10px thumb", () => {
const host = mount(<TimelineToolbar />);
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Timeline zoom"]');
if (!slider) throw new Error("zoom slider did not render");
expect(slider.className).toContain("h-6");
expect(slider.className).toContain("[&::-webkit-slider-runnable-track]:h-[2px]");
expect(slider.className).toContain("[&::-webkit-slider-thumb]:h-[10px]");
expect(slider.className).toContain("[&::-webkit-slider-thumb]:w-[10px]");
});
it("gives the composition card's render button a 24x24 box around its 14px glyph", () => {
const host = mount(
<CompositionsTab
projectId="demo"
compositions={["compositions/headline.html"]}
activeComposition={null}
onSelect={vi.fn()}
onRenderComposition={vi.fn()}
/>,
);
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Render headline"]');
if (!button) throw new Error("render button did not render");
expect(button.className).toContain("h-6");
expect(button.className).toContain("w-6");
expect(button.querySelector("svg")?.getAttribute("width")).toBe("14");
});
});
@@ -317,7 +317,10 @@ function CompCard({
e.stopPropagation();
onRender();
}}
className={`flex-shrink-0 p-1 rounded transition-colors ${
// h-6 w-6 = the 24x24 WCAG 2.2 (2.5.8) minimum target; the 14px glyph
// is unchanged, only the box grows. The sibling "+" button is h-8 w-8,
// so the card row already has the room.
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-colors ${
isRendering
? "text-neutral-600 cursor-not-allowed"
: "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
@@ -27,6 +27,12 @@ export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
// stops there: at the zoom floor the gap alone left a ~7px target, which is
// neither hittable nor selectable with any accuracy. Boxes may overlap slightly
// below this width; each diamond still owns the half-gap around its own centre.
//
// Deliberately below the 24px WCAG 2.2 (2.5.8) minimum, under that criterion's
// own spacing exception: at normal keyframe density the neighbour gap is under
// 24px, so a 24px floor would make adjacent diamonds' hit boxes OVERLAP — one
// diamond swallowing its neighbour's clicks is a worse 2.5.8 failure than a
// small target. Do not "fix" this to 24.
const KF_MIN_HIT_W = 12;
/** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */
@@ -141,6 +147,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
? Math.round(clipHeightPx * 0.45)
: Math.round(LANE_H * DIAMOND_RATIO);
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
// Hit-box height only — the glyph keeps `marker.visualSize`. 24 is the WCAG 2.2
// (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
// shrunken box: 24px there would reach up into the beat strip.
const hitHeight = beatsActive ? diamondSize : 24;
const sorted = keyframesData.keyframes
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
.sort((a, b) => a.percentage - b.percentage);
@@ -412,7 +422,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
top: centerY,
transform: "translateY(-50%)",
width: marker.hitWidth,
height: diamondSize,
height: hitHeight,
zIndex: isHighlighted ? 2 : 1,
pointerEvents: "auto",
background: "none",
@@ -103,7 +103,10 @@ export function TimelineDiamondConnectors({
data-keyframe-ease-button=""
aria-label={`Edit ${ease} easing`}
title={`Edit ${ease} easing`}
className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
// A visible 24x24 badge would collide with the diamonds either
// side, so the WCAG 2.2 (2.5.8) target is met with a centered
// transparent ::before overlay; the box stays 16x16.
className="absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']"
style={{
left: "50%",
top: "50%",