mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): meet the 24x24 pointer target minimum on toolbar and lane controls
This commit is contained in:
@@ -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%",
|
||||
|
||||
Reference in New Issue
Block a user