@hyperframes/studio 0.7.77 → 0.7.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/assets/{hyperframes-player-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1432 -1181
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioLeftSidebar.tsx +10 -5
  11. package/src/components/StudioRightPanel.tsx +5 -4
  12. package/src/components/TimelineToolbar.tsx +3 -1
  13. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +4 -1
  15. package/src/components/editor/PropertyPanel.tsx +3 -7
  16. package/src/components/editor/keyframeRetime.test.ts +42 -0
  17. package/src/components/editor/keyframeRetime.ts +4 -1
  18. package/src/components/editor/motionPathSelection.test.ts +65 -0
  19. package/src/components/editor/motionPathSelection.ts +13 -3
  20. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  21. package/src/components/editor/propertyPanelColor.tsx +32 -26
  22. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  23. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  24. package/src/components/pointerTargetSize.test.tsx +65 -0
  25. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +70 -33
  27. package/src/hooks/gsapDragCommit.ts +43 -10
  28. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  29. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  31. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  32. package/src/hooks/gsapResizeIntercept.ts +2 -2
  33. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  34. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  35. package/src/hooks/gsapShared.test.ts +144 -0
  36. package/src/hooks/gsapShared.ts +264 -0
  37. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  38. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  39. package/src/hooks/newTweenTarget.test.ts +364 -0
  40. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  41. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  42. package/src/hooks/timelineTrackVisibility.ts +11 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  44. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  45. package/src/hooks/useEnableKeyframes.ts +18 -5
  46. package/src/hooks/useGestureCommit.ts +21 -4
  47. package/src/hooks/useGsapScriptCommits.ts +3 -0
  48. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  49. package/src/hooks/useGsapTweenCache.ts +4 -11
  50. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  51. package/src/player/components/Player.test.ts +143 -12
  52. package/src/player/components/Player.tsx +84 -43
  53. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  54. package/src/player/components/ShortcutsPanel.tsx +11 -15
  55. package/src/player/components/Timeline.test.ts +3 -1
  56. package/src/player/components/TimelineCanvas.tsx +2 -1
  57. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  58. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  59. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  60. package/src/player/components/TimelineLanes.test.tsx +281 -0
  61. package/src/player/components/TimelineLanes.tsx +43 -97
  62. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  63. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  64. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  65. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  66. package/src/player/components/timelineLaneProps.ts +86 -0
  67. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  68. package/src/player/components/timelineTrackDisplay.ts +38 -0
  69. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  70. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  72. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  73. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  74. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  75. package/src/telemetry/client.test.ts +12 -3
  76. package/src/telemetry/client.ts +3 -2
  77. package/dist/assets/index-CBAfprvx.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.77",
3
+ "version": "0.7.79",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.77",
50
- "@hyperframes/parsers": "0.7.77",
51
- "@hyperframes/player": "0.7.77",
52
- "@hyperframes/sdk": "0.7.77",
53
- "@hyperframes/studio-server": "0.7.77"
49
+ "@hyperframes/core": "0.7.79",
50
+ "@hyperframes/player": "0.7.79",
51
+ "@hyperframes/sdk": "0.7.79",
52
+ "@hyperframes/studio-server": "0.7.79",
53
+ "@hyperframes/parsers": "0.7.79"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.77"
68
+ "@hyperframes/producer": "0.7.79"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -154,10 +154,10 @@ export function StudioLeftSidebar({
154
154
  onAddAssetToTimeline={onAddAssetToTimeline}
155
155
  onAddCompositionToTimeline={onAddCompositionToTimeline}
156
156
  />
157
- {/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
157
+ {/* Vertical resize divider: 3px visible seam, 13px pointer-capture zone via
158
158
  the absolutely-positioned inner hit area. The outer element is w-[3px] so
159
- it contributes only 3px of gap in the flex row; the inner -left-[2.5px]
160
- element widens the hit area to 8px without affecting layout. */}
159
+ it contributes only 3px of gap in the flex row; the inner -left-[2px]
160
+ element widens the hit area without affecting layout. */}
161
161
  <div
162
162
  role="separator"
163
163
  aria-label="Resize sidebar"
@@ -176,8 +176,13 @@ export function StudioLeftSidebar({
176
176
  adjustPanelWidth("left", delta);
177
177
  }}
178
178
  >
179
- {/* Expanded hit zone: 8px wide, centered on the 3px seam */}
180
- <div className="absolute inset-y-0 -left-[2.5px] w-2" />
179
+ {/* Expanded hit zone, deliberately asymmetric: 2px into the sidebar card,
180
+ the 3px seam, then 8px into the preview pane's p-2 stage gutter — the
181
+ only dead space adjacent to this seam. It stops at 13px rather than the
182
+ 24px WCAG 2.2 (2.5.8) target because the next pixel on either side is
183
+ live: the sidebar's scrolling tab content on the left, the preview
184
+ stage on the right. Silently stealing their clicks is the worse bug. */}
185
+ <div className="absolute inset-y-0 -left-[2px] w-[13px]" />
181
186
  {/* Visible hairline */}
182
187
  <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" />
183
188
  </div>
@@ -446,8 +446,7 @@ export function StudioRightPanel({
446
446
 
447
447
  return (
448
448
  <>
449
- {/* Vertical resize divider: 3px visible seam, 8px pointer-capture zone via
450
- the absolutely-positioned inner hit area. */}
449
+ {/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */}
451
450
  <div
452
451
  role="separator"
453
452
  aria-label="Resize inspector panel"
@@ -467,8 +466,10 @@ export function StudioRightPanel({
467
466
  adjustPanelWidth("right", delta);
468
467
  }}
469
468
  >
470
- {/* Expanded hit zone: 8px wide, centered on the 3px seam */}
471
- <div className="absolute inset-y-0 -left-[2.5px] w-2" />
469
+ {/* Asymmetric hit zone: 8px into the preview's p-2 gutter (the only dead
470
+ space), the 3px seam, 2px into the card. Stops short of the 24px WCAG
471
+ 2.5.8 target because the next pixel each way is live. */}
472
+ <div className="absolute inset-y-0 -left-[8px] w-[13px]" />
472
473
  {/* Visible hairline */}
473
474
  <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" />
474
475
  </div>
@@ -449,7 +449,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
449
449
  setZoomMode("manual");
450
450
  setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value)));
451
451
  }}
452
- 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"
452
+ // h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
453
+ // track stays 2px and the thumb 10px, only the pointer box grows.
454
+ 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"
453
455
  />
454
456
  <Tooltip label="Zoom in">
455
457
  <button
@@ -167,6 +167,11 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
167
167
  };
168
168
 
169
169
  return (
170
+ // The two 12x20 steppers sit gap-0.5 apart with a 9px diamond between them,
171
+ // so they stay below the 24px WCAG 2.2 (2.5.8) minimum under that criterion's
172
+ // own spacing/inline exception: centred 24px targets here would overlap each
173
+ // other AND the diamond, and one control swallowing its neighbour's clicks is
174
+ // a worse 2.5.8 failure than a small target. Do not "fix" these to 24.
170
175
  <div className="flex h-5 items-center gap-0.5">
171
176
  <button
172
177
  type="button"
@@ -132,7 +132,10 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
132
132
  const createMode = geometryResolved && !geometry && Boolean(selection?.element) && !isPlaying;
133
133
  const createSelector = createMode ? selectorFor(selection) : null;
134
134
  const compW = compositionSize?.width ?? null;
135
- const canCreate = createMode && hasMotionPathPlugin(iframeRef.current);
135
+ // No one-element selector means the path could only be authored onto the
136
+ // element's class siblings, so the toolbar toggle stays hidden instead of
137
+ // arming a press that the effect below would silently drop.
138
+ const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);
136
139
 
137
140
  // Publish whether the selected element can take a path so the preview toolbar
138
141
  // shows its "set destination" toggle. Drops to false when this overlay unmounts
@@ -16,7 +16,7 @@ import {
16
16
  } from "./propertyPanelHelpers";
17
17
  import { MetricField, Section } from "./propertyPanelPrimitives";
18
18
  import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
19
- import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
19
+ import { resolveAnimIdForProperty } from "../../player/components/TimelinePropertyLanes";
20
20
  import { resolveEditingSections } from "@hyperframes/core/editing";
21
21
  import { MediaSection } from "./propertyPanelMediaSection";
22
22
  import { ColorGradingSection } from "./propertyPanelColorGradingSection";
@@ -241,12 +241,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
241
241
  const navKeyframes = cacheEntry?.keyframes ?? gsapKeyframes;
242
242
  const seekFromKfPct = (pct: number) => onSeekToTime?.(elStart + (pct / 100) * elDuration);
243
243
 
244
- const animIdForProp = (prop: string): string => {
245
- const group = classifyPropertyGroup(prop);
246
- const groupAnim = gsapAnimations?.find((a) => a.propertyGroup === group);
247
- if (groupAnim) return groupAnim.id;
248
- return gsapAnimId ?? "";
249
- };
244
+ const animIdForProp = (prop: string): string =>
245
+ resolveAnimIdForProperty(prop, gsapAnimations, gsapAnimId);
250
246
 
251
247
  const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
252
248
  const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
@@ -180,3 +180,45 @@ describe("resolveKeyframeRetime — guards", () => {
180
180
  expect(r.pctRemap).toEqual([]);
181
181
  });
182
182
  });
183
+
184
+ describe("resolveKeyframeRetime — move percentages are rounded like the resize path", () => {
185
+ // The move branch used to return the raw quotient, so `74.81203007518799%`
186
+ // landed in the user's source and churned the diff on every drag.
187
+ const decimals = (n: number): number => String(n).split(".")[1]?.length ?? 0;
188
+
189
+ it("rounds a repeating quotient to 3dp", () => {
190
+ const r = resolveKeyframeRetime({
191
+ tweenStart: 2,
192
+ tweenDuration: 3,
193
+ keyframes: KEYFRAMES,
194
+ draggedTweenPct: 0,
195
+ dropAbsTime: 3, // (3-2)/3 = 33.333333333333336%
196
+ });
197
+ expect(r.kind).toBe("move");
198
+ expect(r.toTweenPct).toBe(33.333);
199
+ });
200
+
201
+ it("never emits more than 3 decimal places", () => {
202
+ for (const tweenDuration of [3, 7, 9, 11, 133]) {
203
+ const r = resolveKeyframeRetime({
204
+ tweenStart: 2,
205
+ tweenDuration,
206
+ keyframes: KEYFRAMES,
207
+ draggedTweenPct: 0,
208
+ dropAbsTime: 3,
209
+ });
210
+ expect(r.kind).toBe("move");
211
+ expect(decimals(r.toTweenPct ?? 0)).toBeLessThanOrEqual(3);
212
+ }
213
+ });
214
+
215
+ it("leaves an already-short percentage untouched", () => {
216
+ const r = resolveKeyframeRetime({
217
+ ...WINDOW,
218
+ keyframes: KEYFRAMES,
219
+ draggedTweenPct: 0,
220
+ dropAbsTime: 3, // (3-2)/4 = exactly 25%
221
+ });
222
+ expect(r.toTweenPct).toBe(25);
223
+ });
224
+ });
@@ -121,7 +121,10 @@ export function resolveKeyframeRetime(opts: {
121
121
 
122
122
  // Within the tween window → plain move (re-key the tween-%).
123
123
  if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
124
- const toTweenPct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
124
+ // Round here, not at the return: the no-op test below and the value written
125
+ // to source must be the same number. The resize branch already rounds, so
126
+ // this keeps both write paths at the authored 3dp precision.
127
+ const toTweenPct = round3(clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100));
125
128
  if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
126
129
  return { kind: "move", toTweenPct };
127
130
  }
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import type { DomEditSelection } from "./domEditingTypes";
4
+ import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
5
+ import { selectorFor } from "./motionPathSelection";
6
+
7
+ afterEach(() => {
8
+ document.body.innerHTML = "";
9
+ });
10
+
11
+ function selectionFor(el: HTMLElement): DomEditSelection {
12
+ const selector = buildStableSelector(el);
13
+ return {
14
+ element: el,
15
+ id: el.id || undefined,
16
+ hfId: el.getAttribute("data-hf-id") || undefined,
17
+ selector,
18
+ selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
19
+ sourceFile: "index.html",
20
+ dataAttributes: { start: "0", duration: "2" },
21
+ } as unknown as DomEditSelection;
22
+ }
23
+
24
+ function mountGroupSiblings(): HTMLElement[] {
25
+ document.body.innerHTML = `
26
+ <div id="scene" class="clip" data-start="0" data-duration="2">
27
+ <div class="group"></div>
28
+ <div class="group"></div>
29
+ <div class="group"></div>
30
+ </div>
31
+ `;
32
+ return Array.from(document.querySelectorAll<HTMLElement>(".group"));
33
+ }
34
+
35
+ describe("selectorFor", () => {
36
+ it("addresses one element for a class-only sibling", () => {
37
+ const groups = mountGroupSiblings();
38
+ const selector = selectorFor(selectionFor(groups[2]!));
39
+
40
+ // The bare ".group" both measured home off the FIRST sibling and wrote the
41
+ // new motion path onto all three.
42
+ expect(selector).not.toBe(".group");
43
+ expect(document.querySelectorAll(selector!)).toHaveLength(1);
44
+ expect(document.querySelector(selector!)).toBe(groups[2]);
45
+ });
46
+
47
+ it("keeps a unique id target", () => {
48
+ document.body.innerHTML = `<div id="hero"></div>`;
49
+ const el = document.querySelector<HTMLElement>("#hero")!;
50
+
51
+ expect(selectorFor(selectionFor(el))).toBe("#hero");
52
+ });
53
+
54
+ it("returns null with no selection", () => {
55
+ expect(selectorFor(null)).toBeNull();
56
+ });
57
+
58
+ it("returns null when no rung addresses one element", () => {
59
+ const groups = mountGroupSiblings();
60
+ const selection = selectionFor(groups[1]!);
61
+ groups[1]!.remove();
62
+
63
+ expect(selectorFor(selection)).toBeNull();
64
+ });
65
+ });
@@ -5,11 +5,21 @@
5
5
  */
6
6
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
7
7
  import type { DomEditSelection } from "./domEditing";
8
+ import { writeTargetSelector } from "../../hooks/gsapShared";
8
9
 
10
+ /**
11
+ * The selector the overlay both MEASURES the element by and authors a new
12
+ * motion path against.
13
+ *
14
+ * Both halves need exactly one element. The selection's own selector is a bare
15
+ * class for an id-less element, so a `.group` sibling read its home position off
16
+ * the FIRST sibling (skewing the destination the click computes) and then wrote
17
+ * `add-motion-path` onto all five. `writeTargetSelector` is the same one-element
18
+ * narrowing every other new-tween writer goes through; null means no such form
19
+ * exists, and the overlay hides "set destination" rather than write a wrong one.
20
+ */
9
21
  export function selectorFor(sel: DomEditSelection | null): string | null {
10
- if (!sel) return null;
11
- if (sel.id) return `#${CSS.escape(sel.id)}`;
12
- return sel.selector ?? null;
22
+ return sel ? writeTargetSelector(sel) : null;
13
23
  }
14
24
 
15
25
  /** The animation whose path is editable on-canvas: literal, statically resolved,
@@ -1,43 +1,85 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
6
7
  import { ColorField } from "./propertyPanelColor";
7
8
 
9
+ const trackStudioEvent = vi.hoisted(() => vi.fn());
10
+
11
+ vi.mock("../../utils/studioTelemetry", () => ({
12
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
13
+ }));
14
+
8
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
16
 
17
+ const roots: Root[] = [];
18
+
19
+ beforeEach(() => {
20
+ trackStudioEvent.mockReset();
21
+ __resetDesignInputThrottle();
22
+ });
23
+
10
24
  afterEach(() => {
25
+ for (const root of roots) act(() => root.unmount());
26
+ roots.length = 0;
11
27
  document.body.innerHTML = "";
12
28
  });
13
29
 
14
- function renderColorField(onCommit: (value: string) => void): void {
30
+ function renderColorField({
31
+ value = "#333333",
32
+ onPreview,
33
+ onCommit = vi.fn(),
34
+ }: {
35
+ value?: string;
36
+ onPreview?: (value: string) => void;
37
+ onCommit?: (value: string) => void;
38
+ } = {}): HTMLElement {
15
39
  const host = document.createElement("div");
16
40
  document.body.append(host);
17
41
  const root = createRoot(host);
42
+ roots.push(root);
18
43
  act(() => {
19
- root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={onCommit} />);
44
+ root.render(
45
+ <ColorField flat label="Color" value={value} onPreview={onPreview} onCommit={onCommit} />,
46
+ );
20
47
  });
48
+ return host;
49
+ }
50
+
51
+ function changeInput(input: HTMLInputElement, value: string): void {
52
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
53
+ if (!setter) throw new Error("expected native input value setter");
54
+ setter.call(input, value);
55
+ input.dispatchEvent(new Event("input", { bubbles: true }));
56
+ }
57
+
58
+ function openHexInput(host: HTMLElement): HTMLInputElement {
59
+ const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
60
+ if (!trigger) throw new Error("Color trigger was not rendered");
61
+ act(() => trigger.click());
62
+ const input = document.querySelector<HTMLInputElement>('input[spellcheck="false"]');
63
+ if (!input) throw new Error("Hex input was not rendered");
64
+ return input;
65
+ }
66
+
67
+ function clickOutside(): void {
68
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
21
69
  }
22
70
 
23
71
  describe("ColorField flat trigger", () => {
24
72
  it("renders label and value inline with a small swatch, no boxed border", () => {
25
- const host = document.createElement("div");
26
- document.body.append(host);
27
- const root = createRoot(host);
28
- act(() => {
29
- root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={vi.fn()} />);
30
- });
73
+ const host = renderColorField({ value: "rgb(255, 176, 32)" });
31
74
  const trigger = host.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
32
75
  expect(trigger).not.toBeNull();
33
76
  expect(trigger?.className).not.toContain("border-neutral-800");
34
77
  expect(host.textContent).toContain("Color");
35
- act(() => root.unmount());
36
78
  });
37
79
 
38
80
  it("persists one keyboard slider gesture on keyup", () => {
39
81
  const onCommit = vi.fn();
40
- renderColorField(onCommit);
82
+ renderColorField({ value: "rgb(255, 176, 32)", onCommit });
41
83
  const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
42
84
  if (!trigger) throw new Error("Color trigger was not rendered");
43
85
  act(() => {
@@ -57,3 +99,130 @@ describe("ColorField flat trigger", () => {
57
99
  expect(onCommit).toHaveBeenCalledOnce();
58
100
  });
59
101
  });
102
+
103
+ describe("ColorField hex editing", () => {
104
+ it("allows #333333 to be backspaced to #3 without snapping", () => {
105
+ const input = openHexInput(renderColorField());
106
+
107
+ for (const value of ["#33333", "#3333", "#333", "#33", "#3"]) {
108
+ act(() => changeInput(input, value));
109
+ expect(input.value).toBe(value);
110
+ }
111
+ });
112
+
113
+ it("does not silently change #22CC66 to #2222CC while backspacing", () => {
114
+ const input = openHexInput(renderColorField({ value: "#22CC66" }));
115
+
116
+ for (const value of ["#22CC6", "#22CC", "#22C"]) {
117
+ act(() => changeInput(input, value));
118
+ expect(input.value).toBe(value);
119
+ expect(input.value).not.toBe("#2222CC");
120
+ }
121
+ });
122
+
123
+ it("commits a full replacement after selecting the existing value", () => {
124
+ const onCommit = vi.fn();
125
+ const input = openHexInput(renderColorField({ onCommit }));
126
+ input.focus();
127
+ input.select();
128
+
129
+ act(() => changeInput(input, "#12AB34"));
130
+ act(() => input.blur());
131
+
132
+ expect(onCommit).toHaveBeenCalledOnce();
133
+ expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
134
+ });
135
+
136
+ it("commits a complete pending hex on outside-click", () => {
137
+ const onCommit = vi.fn();
138
+ const input = openHexInput(renderColorField({ onCommit }));
139
+
140
+ act(() => changeInput(input, "#12AB34"));
141
+ act(clickOutside);
142
+
143
+ expect(onCommit).toHaveBeenCalledOnce();
144
+ expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
145
+ });
146
+
147
+ it("commits a 3-digit hex shorthand on outside-click", () => {
148
+ // parseCssColor accepts shorthand, so the gesture resolver has to as well;
149
+ // #F00 is ordinary designer input and used to be dropped in silence.
150
+ const onCommit = vi.fn();
151
+ const input = openHexInput(renderColorField({ onCommit }));
152
+
153
+ act(() => changeInput(input, "#F00"));
154
+ act(clickOutside);
155
+
156
+ expect(onCommit).toHaveBeenCalledOnce();
157
+ expect(onCommit).toHaveBeenCalledWith("rgb(255, 0, 0)");
158
+ });
159
+
160
+ it("does not commit an incomplete pending hex on outside-click", () => {
161
+ const onCommit = vi.fn();
162
+ const host = renderColorField({ value: "#224466", onCommit });
163
+ const input = openHexInput(host);
164
+
165
+ act(() => changeInput(input, "#12AB3"));
166
+ act(clickOutside);
167
+
168
+ expect(onCommit).not.toHaveBeenCalled();
169
+ // The settle also has to put the field back, or the panel re-opens showing
170
+ // a value the composition never took.
171
+ expect(openHexInput(host).value).toBe("#224466");
172
+ });
173
+
174
+ it("cancels a pending hex edit on Escape and restores the previous value", () => {
175
+ const onPreview = vi.fn();
176
+ const onCommit = vi.fn();
177
+ const host = renderColorField({ value: "#224466", onPreview, onCommit });
178
+ const input = openHexInput(host);
179
+
180
+ act(() => changeInput(input, "#12AB34"));
181
+ act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
182
+
183
+ expect(onCommit).not.toHaveBeenCalled();
184
+ expect(onPreview).toHaveBeenLastCalledWith("rgb(34, 68, 102)");
185
+ expect(openHexInput(host).value).toBe("#224466");
186
+ });
187
+
188
+ it("still commits a complete hex on Tab-blur", () => {
189
+ const onCommit = vi.fn();
190
+ const input = openHexInput(renderColorField({ onCommit }));
191
+ input.focus();
192
+
193
+ act(() => changeInput(input, "#12AB34"));
194
+ act(() => input.blur());
195
+
196
+ expect(onCommit).toHaveBeenCalledOnce();
197
+ expect(onCommit).toHaveBeenCalledWith("rgb(18, 171, 52)");
198
+ });
199
+
200
+ it("live-previews only a hex length that parses, 3 or 6 digits", () => {
201
+ const onPreview = vi.fn();
202
+ const input = openHexInput(renderColorField({ value: "#112233", onPreview }));
203
+
204
+ // 4 and 5 digits are mid-typing, so they must stay silent.
205
+ act(() => changeInput(input, "#3333"));
206
+ act(() => changeInput(input, "#33333"));
207
+ expect(onPreview).not.toHaveBeenCalled();
208
+
209
+ act(() => changeInput(input, "#333333"));
210
+ expect(onPreview).toHaveBeenCalledOnce();
211
+ expect(onPreview).toHaveBeenCalledWith("rgb(51, 51, 51)");
212
+
213
+ act(() => changeInput(input, "#333"));
214
+ expect(onPreview).toHaveBeenCalledTimes(2);
215
+ expect(onPreview).toHaveBeenLastCalledWith("rgb(51, 51, 51)");
216
+ });
217
+
218
+ it("tracks exactly once per completed edit, not once per keystroke", () => {
219
+ const input = openHexInput(renderColorField());
220
+
221
+ for (const value of ["#", "#1", "#12", "#12A", "#12AB", "#12AB3", "#12AB34"]) {
222
+ act(() => changeInput(input, value));
223
+ }
224
+ act(clickOutside);
225
+
226
+ expect(trackStudioEvent).toHaveBeenCalledOnce();
227
+ });
228
+ });
@@ -184,11 +184,27 @@ export function ColorField({
184
184
  const brightnessPercent = Math.round(hsv.value * 100);
185
185
  const alphaPercent = Math.round(draftColor.alpha * 100);
186
186
 
187
- const updateColorDraft = useCallback((nextValue: string) => {
187
+ const updateColorDraft = useCallback((nextValue: string, source: "hex" | "picker") => {
188
188
  const nextColor = parseCssColor(nextValue);
189
189
  if (!nextColor) return;
190
190
  setDraftColor(nextColor);
191
- setHexDraft(toHexColor(nextColor).toUpperCase());
191
+ if (source === "picker") setHexDraft(toHexColor(nextColor).toUpperCase());
192
+ }, []);
193
+ const resolveColorGestureValue = useCallback((nextValue: string) => {
194
+ const source = nextValue.startsWith("#") ? "hex" : "picker";
195
+ // Only a COMPLETE hex resolves, so a half-typed one neither previews nor
196
+ // commits. Both lengths parseCssColor accepts count as complete: gating on
197
+ // 6 alone silently dropped #F00 and friends, which the old onBlur committed.
198
+ if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null;
199
+ const nextColor = parseCssColor(nextValue);
200
+ if (!nextColor) return null;
201
+ return {
202
+ source,
203
+ value: formatCssColor({
204
+ ...nextColor,
205
+ alpha: source === "hex" ? draftColorRef.current.alpha : nextColor.alpha,
206
+ }),
207
+ } as const;
192
208
  }, []);
193
209
  const persistColorValue = useCallback(
194
210
  (nextValue: string) => {
@@ -203,12 +219,17 @@ export function ColorField({
203
219
  settle: settleColorGesture,
204
220
  cancel: cancelColorGesture,
205
221
  } = useInspectorGestureTransaction({
206
- sourceValue: value,
222
+ sourceValue: formatCssColor(colorFromCss(value)),
207
223
  onPreview: (nextValue) => {
208
- updateColorDraft(nextValue);
209
- onPreview?.(nextValue);
224
+ const resolved = resolveColorGestureValue(nextValue);
225
+ if (!resolved) return;
226
+ updateColorDraft(resolved.value, resolved.source);
227
+ onPreview?.(resolved.value);
228
+ },
229
+ onCommit: (nextValue) => {
230
+ const resolved = resolveColorGestureValue(nextValue);
231
+ if (resolved) persistColorValue(resolved.value);
210
232
  },
211
- onCommit: persistColorValue,
212
233
  });
213
234
 
214
235
  useEffect(() => {
@@ -288,13 +309,11 @@ export function ColorField({
288
309
  commitHsv({ saturation, value: nextValue });
289
310
  };
290
311
 
291
- const handleHexCommit = (nextHex: string) => {
312
+ const handleHexChange = (nextHex: string) => {
292
313
  setHexDraft(nextHex);
293
314
  const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
294
- const parsed = parseCssColor(normalized);
295
- if (!parsed) return;
296
- const nextValue = formatCssColor({ ...parsed, alpha: draftColorRef.current.alpha });
297
- updateColorDraft(nextValue);
315
+ beginColorGesture();
316
+ previewColorGesture(normalized);
298
317
  };
299
318
 
300
319
  const picker = open
@@ -413,21 +432,8 @@ export function ColorField({
413
432
  <span className={LABEL}>Hex</span>
414
433
  <input
415
434
  value={hexDraft}
416
- onChange={(event) => handleHexCommit(event.target.value)}
417
- onBlur={() => {
418
- const normalized = hexDraft.trim().startsWith("#")
419
- ? hexDraft.trim()
420
- : `#${hexDraft.trim()}`;
421
- const parsed = parseCssColor(normalized);
422
- if (parsed) {
423
- const nextValue = formatCssColor({
424
- ...parsed,
425
- alpha: draftColorRef.current.alpha,
426
- });
427
- persistColorValue(nextValue);
428
- }
429
- setHexDraft(toHexColor(draftColorRef.current).toUpperCase());
430
- }}
435
+ onChange={(event) => handleHexChange(event.target.value)}
436
+ onBlur={settleColorGesture}
431
437
  className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
432
438
  spellCheck={false}
433
439
  />
@@ -189,7 +189,9 @@ export function SliderControl({
189
189
  onMouseUp={() => commitDraft(draft)}
190
190
  onTouchEnd={() => commitDraft(draft)}
191
191
  onBlur={() => commitDraft(draft)}
192
- 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"
192
+ // h-6 is the 24x24 WCAG 2.2 (2.5.8) target: the visible track stays 2px
193
+ // and the thumb 10px, only the pointer box grows.
194
+ 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"
193
195
  />
194
196
  <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">
195
197
  {formatDisplayValue?.(draft) ?? displayValue}