@hyperframes/studio 0.8.6 → 0.8.7

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 (152) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10336 -7195
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  142. package/src/player/lib/timelineDOM.ts +51 -0
  143. package/src/player/store/audioSoloSlice.test.ts +113 -0
  144. package/src/player/store/audioSoloSlice.ts +43 -0
  145. package/src/player/store/editingModeSlice.ts +45 -0
  146. package/src/player/store/groupLevels.ts +33 -0
  147. package/src/player/store/keyframeSlice.ts +26 -0
  148. package/src/player/store/playerStore.ts +21 -22
  149. package/src/player/store/timelineElement.ts +10 -0
  150. package/src/telemetry/events.ts +12 -0
  151. package/dist/assets/index-Cy6fGN1u.js +0 -428
  152. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -1,6 +1,8 @@
1
1
  // fallow-ignore-file code-duplication
2
2
  import { memo, useState, useCallback, useRef, useEffect } from "react";
3
3
  import { createPortal } from "react-dom";
4
+ import { SearchInput } from "../ui/SearchInput";
5
+ import { PromptPreviewModal } from "./PromptPreviewModal";
4
6
  import { useBlockCatalog } from "../../hooks/useBlockCatalog";
5
7
  import {
6
8
  BLOCK_CATEGORIES,
@@ -18,7 +20,7 @@ export interface BlockPreviewInfo {
18
20
  }
19
21
 
20
22
  interface BlocksTabProps {
21
- onAddBlock?: (blockName: string) => void;
23
+ onAddBlock?: (blockName: string) => void | Promise<void>;
22
24
  onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
23
25
  }
24
26
 
@@ -48,29 +50,12 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }:
48
50
  <div className="flex flex-col flex-1 min-h-0">
49
51
  {/* Search */}
50
52
  <div className="px-3 pt-2 pb-1 flex-shrink-0">
51
- <div className="relative">
52
- <svg
53
- className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500"
54
- width="12"
55
- height="12"
56
- viewBox="0 0 24 24"
57
- fill="none"
58
- stroke="currentColor"
59
- strokeWidth="2"
60
- strokeLinecap="round"
61
- strokeLinejoin="round"
62
- >
63
- <circle cx="11" cy="11" r="8" />
64
- <path d="m21 21-4.35-4.35" />
65
- </svg>
66
- <input
67
- type="text"
68
- value={search}
69
- onChange={(e) => setSearch(e.target.value)}
70
- placeholder="Search by name, category, or tag…"
71
- className="w-full bg-neutral-900 border border-neutral-800 rounded-md pl-7 pr-2 py-1.5 text-[11px] text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-neutral-700 transition-colors"
72
- />
73
- </div>
53
+ <SearchInput
54
+ value={search}
55
+ onChange={(e) => setSearch(e.target.value)}
56
+ placeholder="Search by name, category, or tag…"
57
+ aria-label="Search blocks"
58
+ />
74
59
  </div>
75
60
 
76
61
  {/* Category pills */}
@@ -165,7 +150,8 @@ function CategoryPill({
165
150
  <button
166
151
  type="button"
167
152
  onClick={onClick}
168
- className={`flex-shrink-0 px-2 py-1 rounded-full text-[10px] font-medium transition-colors ${
153
+ aria-pressed={active}
154
+ className={`flex-shrink-0 px-2 py-1 rounded-full text-[10px] font-medium transition-colors active:scale-[0.98] ${
169
155
  active
170
156
  ? colors
171
157
  ? `${colors.bg} ${colors.text}`
@@ -304,12 +290,12 @@ function BlockCard({
304
290
  tags?: string[];
305
291
  posterUrl?: string;
306
292
  videoUrl?: string;
307
- onAdd?: () => void;
293
+ onAdd?: () => void | Promise<void>;
308
294
  onShowPrompt?: (info: { title: string; prompt: string }) => void;
309
295
  onPreview?: (preview: BlockPreviewInfo | null) => void;
310
296
  }) {
311
297
  const [hovered, setHovered] = useState(false);
312
- const [adding, setAdding] = useState(false);
298
+ const [addState, setAddState] = useState<"idle" | "adding" | "added" | "failed">("idle");
313
299
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
314
300
  const colors = getCategoryColors(category);
315
301
  const needsWebGL = tags?.includes("html-in-canvas") || tags?.includes("webgl");
@@ -337,14 +323,20 @@ function BlockCard({
337
323
  }, []);
338
324
 
339
325
  const handleAdd = useCallback(
340
- (e: React.MouseEvent) => {
326
+ async (e: React.MouseEvent) => {
341
327
  e.stopPropagation();
342
- if (adding || !onAdd) return;
343
- setAdding(true);
344
- onAdd();
345
- setTimeout(() => setAdding(false), 1000);
328
+ if (addState !== "idle" || !onAdd) return;
329
+ setAddState("adding");
330
+ try {
331
+ // Confirm only what actually happened — no optimistic "Added!".
332
+ await onAdd();
333
+ setAddState("added");
334
+ } catch {
335
+ setAddState("failed");
336
+ }
337
+ setTimeout(() => setAddState("idle"), 1500);
346
338
  },
347
- [onAdd, adding],
339
+ [onAdd, addState],
348
340
  );
349
341
 
350
342
  const { activeCompPath, compositionDimensions } = useStudioShellContext();
@@ -422,14 +414,18 @@ function BlockCard({
422
414
  </div>
423
415
  )}
424
416
 
425
- {/* Action overlay */}
426
- <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 opacity-0 group-hover/card:opacity-100 transition-opacity">
417
+ {/* Action overlay — also revealed when a button inside receives focus */}
418
+ <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100 transition-opacity">
427
419
  {onAdd && (
428
420
  <button
429
421
  type="button"
430
422
  onClick={handleAdd}
431
423
  title="Add to composition at current time"
432
- className="flex items-center gap-1 px-3 py-1.5 rounded-md bg-white text-black text-[10px] font-semibold hover:bg-neutral-200 transition-colors"
424
+ className={`flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-semibold transition-colors active:scale-[0.97] ${
425
+ addState === "failed"
426
+ ? "bg-red-500 text-white"
427
+ : "bg-white text-black hover:bg-neutral-200"
428
+ }`}
433
429
  >
434
430
  <svg
435
431
  width="10"
@@ -441,14 +437,20 @@ function BlockCard({
441
437
  >
442
438
  <path d="M12 5v14M5 12h14" />
443
439
  </svg>
444
- {adding ? "Added!" : "Add"}
440
+ {addState === "adding"
441
+ ? "Adding…"
442
+ : addState === "added"
443
+ ? "Added!"
444
+ : addState === "failed"
445
+ ? "Failed"
446
+ : "Add"}
445
447
  </button>
446
448
  )}
447
449
  <button
448
450
  type="button"
449
451
  onClick={handleShowPrompt}
450
452
  title="Generate a prompt to paste into your AI agent"
451
- className={`flex items-center gap-1.5 px-3 ${onAdd ? "py-1" : "py-1.5"} rounded-md transition-colors ${
453
+ className={`flex items-center gap-1.5 px-3 ${onAdd ? "py-1" : "py-1.5"} rounded-md transition-colors active:scale-[0.97] ${
452
454
  onAdd
453
455
  ? "bg-white/15 text-white/90 hover:bg-white/25 text-[9px]"
454
456
  : "bg-white text-black hover:bg-neutral-200 text-[10px] font-semibold"
@@ -499,93 +501,3 @@ function BlockCard({
499
501
  </div>
500
502
  );
501
503
  }
502
-
503
- function PromptPreviewModal({
504
- title,
505
- prompt,
506
- onClose,
507
- }: {
508
- title: string;
509
- prompt: string;
510
- onClose: () => void;
511
- }) {
512
- const [value, setValue] = useState(prompt);
513
- const [copied, setCopied] = useState(false);
514
- const textareaRef = useRef<HTMLTextAreaElement>(null);
515
-
516
- useEffect(() => {
517
- requestAnimationFrame(() => textareaRef.current?.focus());
518
- }, []);
519
-
520
- const handleCopy = useCallback(() => {
521
- navigator.clipboard.writeText(value);
522
- setCopied(true);
523
- setTimeout(() => setCopied(false), 1500);
524
- }, [value]);
525
-
526
- return (
527
- <div
528
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
529
- onClick={onClose}
530
- >
531
- <div
532
- className="w-[560px] max-h-[80vh] flex flex-col rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
533
- onClick={(e) => e.stopPropagation()}
534
- >
535
- <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
536
- <div>
537
- <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
538
- <p className="text-xs text-neutral-500 mt-0.5">{title}</p>
539
- </div>
540
- <button
541
- className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
542
- onClick={onClose}
543
- >
544
- <svg
545
- width="14"
546
- height="14"
547
- viewBox="0 0 24 24"
548
- fill="none"
549
- stroke="currentColor"
550
- strokeWidth="2"
551
- strokeLinecap="round"
552
- >
553
- <line x1="18" y1="6" x2="6" y2="18" />
554
- <line x1="6" y1="6" x2="18" y2="18" />
555
- </svg>
556
- </button>
557
- </div>
558
- <div className="flex-1 overflow-y-auto px-5 py-4">
559
- <p className="text-[11px] text-neutral-500 mb-2">
560
- Edit the prompt below, then copy and paste into your AI agent
561
- </p>
562
- <textarea
563
- ref={textareaRef}
564
- value={value}
565
- onChange={(e) => setValue(e.target.value)}
566
- onKeyDown={(e) => {
567
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleCopy();
568
- if (e.key === "Escape") onClose();
569
- }}
570
- className="w-full min-h-[240px] text-[11px] text-neutral-200 leading-relaxed font-mono bg-neutral-900/60 rounded-lg p-3 border border-neutral-800 resize-y focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
571
- />
572
- </div>
573
- <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
574
- <span className="text-[11px] text-neutral-600">
575
- {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
576
- </span>
577
- <button
578
- className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors ${
579
- copied
580
- ? "bg-emerald-500 text-white"
581
- : "bg-studio-accent/90 text-neutral-950 hover:bg-studio-accent"
582
- }`}
583
- onClick={handleCopy}
584
- >
585
- {copied ? "Copied!" : "Copy prompt"}
586
- </button>
587
- </div>
588
- </div>
589
- </div>
590
- );
591
- }
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
3
3
  import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
4
4
  import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
5
+ import { Tooltip } from "../ui/Tooltip";
5
6
 
6
7
  interface CompositionsTabProps {
7
8
  projectId: string;
@@ -201,6 +202,8 @@ function CompCard({
201
202
  role="button"
202
203
  tabIndex={0}
203
204
  draggable
205
+ aria-label={`Open composition ${name}`}
206
+ aria-pressed={isActive}
204
207
  onDragStart={(event) => {
205
208
  draggedRef.current = true;
206
209
  event.dataTransfer.effectAllowed = "copy";
@@ -215,6 +218,8 @@ function CompCard({
215
218
  if (!draggedRef.current) onSelect();
216
219
  }}
217
220
  onKeyDown={(event) => {
221
+ // Only when the row itself is focused — keydowns bubbling from the
222
+ // inner controls (play button) must keep their native activation.
218
223
  if (event.target !== event.currentTarget) return;
219
224
  if (event.key === "Enter" || event.key === " ") {
220
225
  event.preventDefault();
@@ -223,7 +228,7 @@ function CompCard({
223
228
  }}
224
229
  onPointerEnter={handleEnter}
225
230
  onPointerLeave={handleLeave}
226
- className={`group/card w-full select-none text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-grab active:cursor-grabbing ${
231
+ className={`group/card w-full select-none text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-grab active:cursor-grabbing outline-none focus-visible:bg-neutral-800/60 ${
227
232
  isActive
228
233
  ? "bg-studio-accent/10 border-l-2 border-studio-accent"
229
234
  : "border-l-2 border-transparent hover:bg-neutral-800/50"
@@ -288,7 +293,12 @@ function CompCard({
288
293
  <div className="flex items-center gap-1">
289
294
  <span className="text-[11px] font-medium text-neutral-300 truncate">{name}</span>
290
295
  {lintInfo && lintInfo.count > 0 && (
291
- <span className="flex-shrink-0 w-2 h-2 rounded-full bg-amber-400" />
296
+ <span
297
+ aria-label={`${lintInfo.count} lint finding${lintInfo.count === 1 ? "" : "s"}`}
298
+ className="flex-shrink-0 min-w-[16px] text-center rounded-full bg-amber-500/20 px-1 text-[8px] font-bold text-amber-400"
299
+ >
300
+ {lintInfo.count}
301
+ </span>
292
302
  )}
293
303
  </div>
294
304
  <span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
@@ -308,39 +318,41 @@ function CompCard({
308
318
  </button>
309
319
  )}
310
320
  {onRender && (
311
- <button
312
- type="button"
313
- title={isRendering ? "Rendering..." : `Render ${name}`}
314
- aria-label={isRendering ? "Rendering..." : `Render ${name}`}
315
- disabled={isRendering}
316
- onClick={(e) => {
317
- e.stopPropagation();
318
- onRender();
319
- }}
320
- // h-6 w-6 = the 24x24 WCAG 2.2 (2.5.8) minimum target; the 14px glyph
321
- // is unchanged, only the box grows. The sibling "+" button is h-8 w-8,
322
- // so the card row already has the room.
323
- className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-colors ${
324
- isRendering
325
- ? "text-neutral-600 cursor-not-allowed"
326
- : "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
327
- }`}
328
- >
329
- <svg
330
- width="14"
331
- height="14"
332
- viewBox="0 0 24 24"
333
- fill="none"
334
- stroke="currentColor"
335
- strokeWidth="2"
336
- strokeLinecap="round"
337
- strokeLinejoin="round"
321
+ <Tooltip label={isRendering ? "A render is already in progress" : `Render ${name}`}>
322
+ <button
323
+ type="button"
324
+ aria-label={isRendering ? "A render is already in progress" : `Render ${name}`}
325
+ disabled={isRendering}
326
+ onClick={(e) => {
327
+ e.stopPropagation();
328
+ onRender();
329
+ }}
330
+ // h-6 w-6 = the 24x24 WCAG 2.2 (2.5.8) minimum target; the 14px glyph
331
+ // is unchanged, only the box grows. The sibling "+" button is h-8 w-8,
332
+ // so the card row already has the room.
333
+ className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded transition-colors ${
334
+ isRendering
335
+ ? "text-neutral-600 cursor-not-allowed"
336
+ : "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
337
+ }`}
338
338
  >
339
- <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
340
- <polyline points="7 10 12 15 17 10" />
341
- <line x1="12" y1="15" x2="12" y2="3" />
342
- </svg>
343
- </button>
339
+ <svg
340
+ width="14"
341
+ height="14"
342
+ viewBox="0 0 24 24"
343
+ fill="none"
344
+ stroke="currentColor"
345
+ strokeWidth="2"
346
+ strokeLinecap="round"
347
+ strokeLinejoin="round"
348
+ aria-hidden="true"
349
+ >
350
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
351
+ <polyline points="7 10 12 15 17 10" />
352
+ <line x1="12" y1="15" x2="12" y2="3" />
353
+ </svg>
354
+ </button>
355
+ </Tooltip>
344
356
  )}
345
357
  </div>
346
358
  );
@@ -24,6 +24,13 @@ export interface LeftSidebarHandle {
24
24
 
25
25
  const STORAGE_KEY = "hf-studio-sidebar-tab";
26
26
 
27
+ const SIDEBAR_TABS: Array<{ id: SidebarTab; label: string; tooltip: string }> = [
28
+ { id: "code", label: "Code", tooltip: "Source code editor" },
29
+ { id: "compositions", label: "Comps", tooltip: "Compositions and sub-compositions" },
30
+ { id: "assets", label: "Assets", tooltip: "Videos, images, audio, fonts" },
31
+ { id: "blocks", label: "Catalog", tooltip: "Browse blocks and components" },
32
+ ];
33
+
27
34
  // Both the `localStorage` reference and `getItem` itself can throw when the
28
35
  // browsing context is partitioned or site data is blocked — the same case
29
36
  // telemetry/config.ts documents. This runs as a `useState` initializer, so an
@@ -49,7 +56,7 @@ interface LeftSidebarProps {
49
56
  assets: string[];
50
57
  activeComposition: string | null;
51
58
  onSelectComposition: (comp: string) => void;
52
- onImportFiles?: (files: FileList, dir?: string) => void;
59
+ onImportFiles?: (files: FileList, dir?: string) => void | Promise<void>;
53
60
  fileTree?: string[];
54
61
  editingFile?: { path: string; content: string | null } | null;
55
62
  onSelectFile?: (path: string) => void;
@@ -67,7 +74,7 @@ interface LeftSidebarProps {
67
74
  lintFindingCount?: number;
68
75
  lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
69
76
  onToggleCollapse?: () => void;
70
- onAddBlock?: (blockName: string) => void;
77
+ onAddBlock?: (blockName: string) => void | Promise<void>;
71
78
  onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
72
79
  takeoverContent?: ReactNode;
73
80
  onAddAssetToTimeline?: (path: string) => void;
@@ -112,6 +119,7 @@ export const LeftSidebar = memo(
112
119
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
113
120
  const tabRef = useRef(tab);
114
121
  tabRef.current = tab;
122
+ const tablistRef = useRef<HTMLDivElement>(null);
115
123
 
116
124
  const selectTab = useCallback((t: SidebarTab) => {
117
125
  setTab(t);
@@ -127,6 +135,20 @@ export const LeftSidebar = memo(
127
135
 
128
136
  useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
129
137
 
138
+ // APG tabs pattern: Left/Right move focus AND selection between tabs.
139
+ const handleTablistKeyDown = useCallback(
140
+ (e: React.KeyboardEvent) => {
141
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
142
+ e.preventDefault();
143
+ const ids = SIDEBAR_TABS.map((t) => t.id);
144
+ const idx = ids.indexOf(tabRef.current);
145
+ const next = ids[(idx + (e.key === "ArrowRight" ? 1 : -1) + ids.length) % ids.length];
146
+ selectTab(next);
147
+ tablistRef.current?.querySelector<HTMLButtonElement>(`[data-tab-id="${next}"]`)?.focus();
148
+ },
149
+ [selectTab],
150
+ );
151
+
130
152
  return (
131
153
  <div
132
154
  className="flex flex-col h-full overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
@@ -140,61 +162,33 @@ export const LeftSidebar = memo(
140
162
  <div className="border-b border-neutral-800/50 px-3 py-3 flex-shrink-0">
141
163
  <div className="flex items-center gap-2">
142
164
  <div
165
+ ref={tablistRef}
166
+ role="tablist"
167
+ aria-label="Sidebar panels"
168
+ onKeyDown={handleTablistKeyDown}
143
169
  className="grid min-w-0 flex-1 gap-0.5 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
144
170
  style={{ gridTemplateColumns: "1fr 1fr 1fr 1fr" }}
145
171
  >
146
- <Tooltip label="Source code editor" side="bottom">
147
- <button
148
- type="button"
149
- onClick={() => selectTab("code")}
150
- className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
151
- tab === "code"
152
- ? "bg-neutral-800 text-white"
153
- : "text-neutral-500 hover:text-neutral-200"
154
- }`}
155
- >
156
- Code
157
- </button>
158
- </Tooltip>
159
- <Tooltip label="Compositions and sub-compositions" side="bottom">
160
- <button
161
- type="button"
162
- onClick={() => selectTab("compositions")}
163
- className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
164
- tab === "compositions"
165
- ? "bg-neutral-800 text-white"
166
- : "text-neutral-500 hover:text-neutral-200"
167
- }`}
168
- >
169
- Comps
170
- </button>
171
- </Tooltip>
172
- <Tooltip label="Videos, images, audio, fonts" side="bottom">
173
- <button
174
- type="button"
175
- onClick={() => selectTab("assets")}
176
- className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
177
- tab === "assets"
178
- ? "bg-neutral-800 text-white"
179
- : "text-neutral-500 hover:text-neutral-200"
180
- }`}
181
- >
182
- Assets
183
- </button>
184
- </Tooltip>
185
- <Tooltip label="Browse blocks and components" side="bottom">
186
- <button
187
- type="button"
188
- onClick={() => selectTab("blocks")}
189
- className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all ${
190
- tab === "blocks"
191
- ? "bg-neutral-800 text-white"
192
- : "text-neutral-500 hover:text-neutral-200"
193
- }`}
194
- >
195
- Catalog
196
- </button>
197
- </Tooltip>
172
+ {SIDEBAR_TABS.map((t) => (
173
+ <Tooltip key={t.id} label={t.tooltip} side="bottom">
174
+ <button
175
+ type="button"
176
+ role="tab"
177
+ data-tab-id={t.id}
178
+ aria-selected={tab === t.id}
179
+ aria-controls={`sidebar-panel-${t.id}`}
180
+ tabIndex={tab === t.id ? 0 : -1}
181
+ onClick={() => selectTab(t.id)}
182
+ className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all active:scale-[0.97] ${
183
+ tab === t.id
184
+ ? "bg-neutral-800 text-white"
185
+ : "text-neutral-500 hover:text-neutral-200"
186
+ }`}
187
+ >
188
+ {t.label}
189
+ </button>
190
+ </Tooltip>
191
+ ))}
198
192
  </div>
199
193
  {onToggleCollapse && (
200
194
  <button
@@ -225,29 +219,41 @@ export const LeftSidebar = memo(
225
219
 
226
220
  {/* Tab content */}
227
221
  {tab === "compositions" && (
228
- <CompositionsTab
229
- projectId={projectId}
230
- compositions={compositions}
231
- activeComposition={activeComposition}
232
- onSelect={onSelectComposition}
233
- onAddToTimeline={onAddCompositionToTimeline}
234
- onRenderComposition={onRenderComposition}
235
- isRendering={isRendering}
236
- lintFindingsByFile={lintFindingsByFile}
237
- />
222
+ <div
223
+ id="sidebar-panel-compositions"
224
+ role="tabpanel"
225
+ className="flex flex-col flex-1 min-h-0"
226
+ >
227
+ <CompositionsTab
228
+ projectId={projectId}
229
+ compositions={compositions}
230
+ activeComposition={activeComposition}
231
+ onSelect={onSelectComposition}
232
+ onAddToTimeline={onAddCompositionToTimeline}
233
+ onRenderComposition={onRenderComposition}
234
+ isRendering={isRendering}
235
+ lintFindingsByFile={lintFindingsByFile}
236
+ />
237
+ </div>
238
238
  )}
239
239
  {tab === "assets" && (
240
- <AssetsTab
241
- projectId={projectId}
242
- assets={assets}
243
- onImport={onImportFiles}
244
- onDelete={onDeleteFile}
245
- onRename={onRenameFile}
246
- onAddAssetToTimeline={onAddAssetToTimeline}
247
- />
240
+ <div
241
+ id="sidebar-panel-assets"
242
+ role="tabpanel"
243
+ className="flex flex-col flex-1 min-h-0"
244
+ >
245
+ <AssetsTab
246
+ projectId={projectId}
247
+ assets={assets}
248
+ onImport={onImportFiles}
249
+ onDelete={onDeleteFile}
250
+ onRename={onRenameFile}
251
+ onAddAssetToTimeline={onAddAssetToTimeline}
252
+ />
253
+ </div>
248
254
  )}
249
255
  {tab === "code" && (
250
- <div className="flex flex-1 min-h-0">
256
+ <div id="sidebar-panel-code" role="tabpanel" className="flex flex-1 min-h-0">
251
257
  {(fileProp?.length ?? 0) > 0 && (
252
258
  <div className="w-[160px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
253
259
  <FileTree
@@ -276,7 +282,13 @@ export const LeftSidebar = memo(
276
282
  )}
277
283
 
278
284
  {tab === "blocks" && (
279
- <BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
285
+ <div
286
+ id="sidebar-panel-blocks"
287
+ role="tabpanel"
288
+ className="flex flex-col flex-1 min-h-0"
289
+ >
290
+ <BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
291
+ </div>
280
292
  )}
281
293
 
282
294
  {/* Lint button pinned at the bottom */}
@@ -285,7 +297,7 @@ export const LeftSidebar = memo(
285
297
  <button
286
298
  onClick={onLint}
287
299
  disabled={linting}
288
- className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
300
+ className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 enabled:hover:text-amber-300 enabled:hover:bg-neutral-800 enabled:active:scale-[0.98] transition-colors disabled:opacity-40"
289
301
  >
290
302
  <svg
291
303
  width="12"