@hyperframes/studio 0.7.45 → 0.7.47

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 (110) hide show
  1. package/dist/assets/index-Dq7FEg0K.css +1 -0
  2. package/dist/assets/{index-CZkdm5YL.js → index-DsckwbdW.js} +1 -1
  3. package/dist/assets/index-VsAbY3rl.js +423 -0
  4. package/dist/assets/{index-CbQKp0Ek.js → index-uvB30_yI.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-CZUbpYhQ.js +0 -416
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -7,7 +7,6 @@ import {
7
7
  type MutableRefObject,
8
8
  type PointerEvent as ReactPointerEvent,
9
9
  } from "react";
10
- import { Tooltip } from "./ui";
11
10
  import { PropertyPanel } from "./editor/PropertyPanel";
12
11
  import { LayersPanel } from "./editor/LayersPanel";
13
12
  import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
@@ -15,6 +14,9 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
15
14
  import { RenderQueue } from "./renders/RenderQueue";
16
15
  import { SlideshowPanel } from "./panels/SlideshowPanel";
17
16
  import type { SceneInfo } from "./panels/SlideshowPanel";
17
+ import { VariablesPanel } from "./panels/VariablesPanel";
18
+ import { PanelTabButton } from "./PanelTabButton";
19
+ import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
18
20
  import type { RenderJob } from "./renders/useRenderQueue";
19
21
  import type { BlockParam } from "@hyperframes/core/registry";
20
22
  import type { IframeWindow } from "../player/lib/playbackTypes";
@@ -22,6 +24,7 @@ import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailabil
22
24
  import type { Composition } from "@hyperframes/sdk";
23
25
  import type { EditHistoryKind } from "../utils/editHistory";
24
26
  import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
27
+ import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
25
28
 
26
29
  import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
27
30
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
@@ -340,63 +343,74 @@ export function StudioRightPanel({
340
343
  );
341
344
 
342
345
  const propertyPanel = (
343
- <PropertyPanel
346
+ <DesignPanelPromoteProvider
347
+ selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
344
348
  projectId={projectId}
345
- projectDir={projectDir}
346
- assets={assets}
347
- element={domEditGroupSelections.length > 1 ? null : domEditSelection}
348
- multiSelectCount={domEditGroupSelections.length}
349
- copiedAgentPrompt={copiedAgentPrompt}
350
- onClearSelection={clearDomSelection}
351
- onToggleElementHidden={onToggleElementHidden}
352
- onUngroup={handleUngroupSelection}
353
- onSetStyle={handleDomStyleCommit}
354
- onSetAttribute={handleDomAttributeCommit}
355
- onSetAttributeLive={handleDomAttributeLiveCommit}
356
- onApplyColorGradingScope={handleApplyColorGradingScope}
357
- onSetHtmlAttribute={handleDomHtmlAttributeCommit}
358
- onRemoveBackground={handleRemoveBackground}
359
- onSetManualOffset={handleDomPathOffsetCommit}
360
- onSetManualSize={handleDomBoxSizeCommit}
361
- onSetManualRotation={handleDomRotationCommit}
362
- onSetText={handleDomTextCommit}
363
- onSetTextFieldStyle={handleDomTextFieldStyleCommit}
364
- onAddTextField={handleDomAddTextField}
365
- onRemoveTextField={handleDomRemoveTextField}
366
- onAskAgent={handleAskAgent}
367
- onImportAssets={handleImportFiles}
368
- fontAssets={fontAssets}
369
- onImportFonts={handleImportFonts}
370
- previewIframeRef={previewIframeRef}
371
- gsapAnimations={selectedGsapAnimations}
372
- gsapMultipleTimelines={gsapMultipleTimelines}
373
- gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
374
- onUpdateGsapProperty={handleGsapUpdateProperty}
375
- onUpdateGsapMeta={handleGsapUpdateMeta}
376
- onDeleteGsapAnimation={handleGsapDeleteAnimation}
377
- onAddGsapProperty={handleGsapAddProperty}
378
- onRemoveGsapProperty={handleGsapRemoveProperty}
379
- onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
380
- onAddGsapFromProperty={handleGsapAddFromProperty}
381
- onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
382
- onAddGsapAnimation={handleGsapAddAnimation}
383
- onCommitAnimatedProperty={commitAnimatedProperty}
384
- onCommitAnimatedProperties={commitAnimatedProperties}
385
- onAddKeyframe={handleGsapAddKeyframe}
386
- onRemoveKeyframe={handleGsapRemoveKeyframe}
387
- onConvertToKeyframes={(animId, duration) =>
388
- handleGsapConvertToKeyframes(animId, undefined, duration)
389
- }
390
- onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
391
- onSetArcPath={handleSetArcPath}
392
- onUpdateArcSegment={handleUpdateArcSegment}
393
- onUnroll={handleUnroll}
394
- onUpdateKeyframeEase={handleUpdateKeyframeEase}
395
- onSetAllKeyframeEases={handleSetAllKeyframeEases}
396
- recordingState={recordingState}
397
- recordingDuration={recordingDuration}
398
- onToggleRecording={onToggleRecording}
399
- />
349
+ activeCompPath={activeCompPath}
350
+ readProjectFile={readProjectFile}
351
+ writeProjectFile={writeProjectFile}
352
+ recordEdit={recordEdit}
353
+ reloadPreview={reloadPreview}
354
+ domEditSaveTimestampRef={domEditSaveTimestampRef}
355
+ >
356
+ <PropertyPanel
357
+ projectId={projectId}
358
+ projectDir={projectDir}
359
+ assets={assets}
360
+ element={domEditGroupSelections.length > 1 ? null : domEditSelection}
361
+ multiSelectCount={domEditGroupSelections.length}
362
+ copiedAgentPrompt={copiedAgentPrompt}
363
+ onClearSelection={clearDomSelection}
364
+ onToggleElementHidden={onToggleElementHidden}
365
+ onUngroup={handleUngroupSelection}
366
+ onSetStyle={handleDomStyleCommit}
367
+ onSetAttribute={handleDomAttributeCommit}
368
+ onSetAttributeLive={handleDomAttributeLiveCommit}
369
+ onApplyColorGradingScope={handleApplyColorGradingScope}
370
+ onSetHtmlAttribute={handleDomHtmlAttributeCommit}
371
+ onRemoveBackground={handleRemoveBackground}
372
+ onSetManualOffset={handleDomPathOffsetCommit}
373
+ onSetManualSize={handleDomBoxSizeCommit}
374
+ onSetManualRotation={handleDomRotationCommit}
375
+ onSetText={handleDomTextCommit}
376
+ onSetTextFieldStyle={handleDomTextFieldStyleCommit}
377
+ onAddTextField={handleDomAddTextField}
378
+ onRemoveTextField={handleDomRemoveTextField}
379
+ onAskAgent={handleAskAgent}
380
+ onImportAssets={handleImportFiles}
381
+ fontAssets={fontAssets}
382
+ onImportFonts={handleImportFonts}
383
+ previewIframeRef={previewIframeRef}
384
+ gsapAnimations={selectedGsapAnimations}
385
+ gsapMultipleTimelines={gsapMultipleTimelines}
386
+ gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
387
+ onUpdateGsapProperty={handleGsapUpdateProperty}
388
+ onUpdateGsapMeta={handleGsapUpdateMeta}
389
+ onDeleteGsapAnimation={handleGsapDeleteAnimation}
390
+ onAddGsapProperty={handleGsapAddProperty}
391
+ onRemoveGsapProperty={handleGsapRemoveProperty}
392
+ onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
393
+ onAddGsapFromProperty={handleGsapAddFromProperty}
394
+ onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
395
+ onAddGsapAnimation={handleGsapAddAnimation}
396
+ onCommitAnimatedProperty={commitAnimatedProperty}
397
+ onCommitAnimatedProperties={commitAnimatedProperties}
398
+ onAddKeyframe={handleGsapAddKeyframe}
399
+ onRemoveKeyframe={handleGsapRemoveKeyframe}
400
+ onConvertToKeyframes={(animId, duration) =>
401
+ handleGsapConvertToKeyframes(animId, undefined, duration)
402
+ }
403
+ onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
404
+ onSetArcPath={handleSetArcPath}
405
+ onUpdateArcSegment={handleUpdateArcSegment}
406
+ onUnroll={handleUnroll}
407
+ onUpdateKeyframeEase={handleUpdateKeyframeEase}
408
+ onSetAllKeyframeEases={handleSetAllKeyframeEases}
409
+ recordingState={recordingState}
410
+ recordingDuration={recordingDuration}
411
+ onToggleRecording={onToggleRecording}
412
+ />
413
+ </DesignPanelPromoteProvider>
400
414
  );
401
415
 
402
416
  const renderQueuePanel = (
@@ -420,6 +434,9 @@ export function StudioRightPanel({
420
434
  format,
421
435
  resolution,
422
436
  composition,
437
+ // Render what the user is previewing: active variable overrides
438
+ // from the Variables panel ride along (undefined = defaults).
439
+ variables: usePreviewVariablesStore.getState().values ?? undefined,
423
440
  });
424
441
  }}
425
442
  compositionDimensions={compositionDimensions}
@@ -461,64 +478,38 @@ export function StudioRightPanel({
461
478
  <div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
462
479
  {STUDIO_INSPECTOR_PANELS_ENABLED && (
463
480
  <>
464
- <Tooltip label="Element styles and properties" side="bottom">
465
- <button
466
- type="button"
467
- onClick={() => handleInspectorPaneButtonClick("design")}
468
- aria-pressed={designPaneOpen}
469
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
470
- designPaneOpen
471
- ? "bg-neutral-800 text-white"
472
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
473
- }`}
474
- >
475
- Design
476
- </button>
477
- </Tooltip>
478
- <Tooltip label="Composition layer stack" side="bottom">
479
- <button
480
- type="button"
481
- onClick={() => handleInspectorPaneButtonClick("layers")}
482
- aria-pressed={layersPaneOpen}
483
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
484
- layersPaneOpen
485
- ? "bg-neutral-800 text-white"
486
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
487
- }`}
488
- >
489
- Layers
490
- </button>
491
- </Tooltip>
481
+ <PanelTabButton
482
+ label="Design"
483
+ tooltip="Element styles and properties"
484
+ active={designPaneOpen}
485
+ onClick={() => handleInspectorPaneButtonClick("design")}
486
+ />
487
+ <PanelTabButton
488
+ label="Layers"
489
+ tooltip="Composition layer stack"
490
+ active={layersPaneOpen}
491
+ onClick={() => handleInspectorPaneButtonClick("layers")}
492
+ />
492
493
  </>
493
494
  )}
494
- <Tooltip label="Render queue and exports" side="bottom">
495
- <button
496
- type="button"
497
- onClick={() => setRightPanelTab("renders")}
498
- aria-pressed={rightPanelTab === "renders"}
499
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
500
- rightPanelTab === "renders"
501
- ? "bg-neutral-800 text-white"
502
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
503
- }`}
504
- >
505
- {renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
506
- </button>
507
- </Tooltip>
508
- <Tooltip label="Slideshow branching editor" side="bottom">
509
- <button
510
- type="button"
511
- onClick={() => setRightPanelTab("slideshow")}
512
- aria-pressed={rightPanelTab === "slideshow"}
513
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
514
- rightPanelTab === "slideshow"
515
- ? "bg-neutral-800 text-white"
516
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
517
- }`}
518
- >
519
- Slideshow
520
- </button>
521
- </Tooltip>
495
+ <PanelTabButton
496
+ label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
497
+ tooltip="Render queue and exports"
498
+ active={rightPanelTab === "renders"}
499
+ onClick={() => setRightPanelTab("renders")}
500
+ />
501
+ <PanelTabButton
502
+ label="Slideshow"
503
+ tooltip="Slideshow branching editor"
504
+ active={rightPanelTab === "slideshow"}
505
+ onClick={() => setRightPanelTab("slideshow")}
506
+ />
507
+ <PanelTabButton
508
+ label="Variables"
509
+ tooltip="Template variables declare, preview with values"
510
+ active={rightPanelTab === "variables"}
511
+ onClick={() => setRightPanelTab("variables")}
512
+ />
522
513
  </div>
523
514
  <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
524
515
  {rightPanelTab === "block-params" && activeBlockParams ? (
@@ -535,6 +526,13 @@ export function StudioRightPanel({
535
526
  onPersist={onPersistSlideshow}
536
527
  onPersistNotes={onPersistSlideshowNotes}
537
528
  />
529
+ ) : rightPanelTab === "variables" ? (
530
+ <VariablesPanel
531
+ sdkSession={sdkSession}
532
+ reloadPreview={reloadPreview}
533
+ domEditSaveTimestampRef={domEditSaveTimestampRef}
534
+ recordEdit={recordEdit}
535
+ />
538
536
  ) : layersPaneOpen && designPaneOpen ? (
539
537
  <div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
540
538
  <div
@@ -14,6 +14,7 @@ import {
14
14
  } from "../../utils/studioHelpers";
15
15
  import { Layers } from "../../icons/SystemIcons";
16
16
  import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
17
+ import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
17
18
 
18
19
  const TAG_ICONS: Record<string, string> = {
19
20
  video: "Vi",
@@ -228,9 +229,7 @@ export const LayersPanel = memo(function LayersPanel() {
228
229
  reordered.splice(toIndex, 0, moved);
229
230
 
230
231
  const existingValues = siblingLayers.map((l) => getElementZIndex(l.element));
231
- const sorted = [...existingValues].sort((a, b) => b - a);
232
- const hasDupes = sorted.some((v, i) => i > 0 && v === sorted[i - 1]);
233
- const zValues = hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted;
232
+ const zValues = computeReorderZValues(existingValues, fromIndex, toIndex);
234
233
 
235
234
  const entries = reordered.map((layer, i) => ({
236
235
  element: layer.element,
@@ -388,25 +387,6 @@ export const LayersPanel = memo(function LayersPanel() {
388
387
 
389
388
  // ── Pure helpers ──────────────────────────────────────────────────────
390
389
 
391
- // fallow-ignore-next-line complexity
392
- function getElementZIndex(element: HTMLElement): number {
393
- try {
394
- const inline = element.style?.zIndex;
395
- if (inline && inline !== "auto") {
396
- const parsed = parseInt(inline, 10);
397
- if (Number.isFinite(parsed)) return parsed;
398
- }
399
- const win = element.ownerDocument?.defaultView;
400
- if (!win) return 0;
401
- const value = win.getComputedStyle(element).zIndex;
402
- if (value === "auto" || value === "") return 0;
403
- const parsed = parseInt(value, 10);
404
- return Number.isFinite(parsed) ? parsed : 0;
405
- } catch {
406
- return 0;
407
- }
408
- }
409
-
410
390
  // fallow-ignore-next-line complexity
411
391
  export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] {
412
392
  if (layers.length <= 1) return layers;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Wraps a Design-panel property control with the promote-to-variable gesture.
3
+ * When a control can be promoted it shows a visible "◇ var" button; clicking it
4
+ * declares a variable (default = current value, so the render is unchanged) and
5
+ * binds this property to it. Once bound, the button is replaced by a "◆ {id}"
6
+ * chip and edits route to the variable's default (edit-in-place). Controls that
7
+ * aren't eligible (or render outside a promote context, or are disabled by the
8
+ * caller) pass through untouched. Uses a render-prop so each control keeps its
9
+ * own value/onCommit shape.
10
+ */
11
+
12
+ import { useEffect } from "react";
13
+ import {
14
+ useVariablePromoteChannel,
15
+ type PromoteChannel,
16
+ } from "../../contexts/VariablePromoteContext";
17
+
18
+ interface RenderArgs {
19
+ /** When bound, the variable's default to display; otherwise undefined. */
20
+ value?: string;
21
+ /** When bound, routes commits to the variable default; otherwise undefined. */
22
+ onCommit?: (value: string) => void;
23
+ bound: boolean;
24
+ }
25
+
26
+ export function PromotableControl({
27
+ channel,
28
+ enabled = true,
29
+ children,
30
+ }: {
31
+ channel: PromoteChannel;
32
+ /**
33
+ * Caller-side gate. Text-section controls only promote when the edited field
34
+ * is the selected element's OWN text (source "self"); binding a child/text-
35
+ * node field would target a different element than the control edits.
36
+ */
37
+ enabled?: boolean;
38
+ children: (args: RenderArgs) => React.ReactNode;
39
+ }) {
40
+ const promote = useVariablePromoteChannel(channel);
41
+
42
+ // A binding attribute (`data-var-*` / `var(--id)`) pointing at a declaration
43
+ // that no longer exists renders as a plain unbound control — a silent
44
+ // fallback that leaves a dev wondering why "their binding isn't showing".
45
+ // Surface it in the console so the dangling reference is discoverable.
46
+ const danglingId =
47
+ enabled && promote && promote.boundId != null && promote.declaration == null
48
+ ? promote.boundId
49
+ : null;
50
+ useEffect(() => {
51
+ if (danglingId != null) {
52
+ console.warn(
53
+ `[hyperframes] Control is bound to variable "${danglingId}", but no such declaration exists. The element still carries the binding on disk — re-declare the variable or unbind the element.`,
54
+ );
55
+ }
56
+ }, [danglingId]);
57
+
58
+ if (!promote || !enabled) return <>{children({ bound: false })}</>;
59
+
60
+ // A binding whose declaration was removed elsewhere is dangling: don't show
61
+ // it as an editable bound control (setDefault would silently no-op) — let it
62
+ // fall back to a plain, re-promotable control.
63
+ const bound = promote.boundId != null && promote.declaration != null;
64
+ const canPromote = promote.action != null && !bound;
65
+ const defaultValue = promote.declaration?.default;
66
+
67
+ const rendered = children(
68
+ bound
69
+ ? {
70
+ // Only string defaults render inline; a FontValue/ImageValue object
71
+ // falls back to the element's real value instead of "[object Object]".
72
+ value: typeof defaultValue === "string" ? defaultValue : undefined,
73
+ onCommit: promote.setDefault,
74
+ bound: true,
75
+ }
76
+ : { bound: false },
77
+ );
78
+
79
+ return (
80
+ <div className={`relative ${bound ? "rounded-lg ring-1 ring-studio-accent/40" : ""}`}>
81
+ {rendered}
82
+ {bound && (
83
+ <span
84
+ className="pointer-events-none absolute right-1.5 top-0 z-10 inline-flex max-w-[60%] items-center gap-1 truncate rounded bg-studio-accent/20 px-1 py-px font-mono text-[8px] font-medium text-studio-accent"
85
+ title={`Bound to variable "${promote.boundId}"`}
86
+ >
87
+ ◆ {promote.boundId}
88
+ </span>
89
+ )}
90
+ {canPromote && (
91
+ <button
92
+ type="button"
93
+ title="Make this a variable"
94
+ onClick={(e) => {
95
+ e.stopPropagation();
96
+ promote.promote();
97
+ }}
98
+ className="absolute right-1.5 top-0 z-10 inline-flex items-center gap-1 rounded bg-neutral-800/80 px-1 py-px font-mono text-[8px] font-medium text-neutral-400 opacity-70 transition-colors hover:bg-studio-accent/20 hover:text-studio-accent hover:opacity-100"
99
+ >
100
+ ◇ var
101
+ </button>
102
+ )}
103
+ </div>
104
+ );
105
+ }
@@ -6,6 +6,7 @@ import { FIELD, LABEL, normalizeTextMetricValue, RESPONSIVE_GRID } from "./prope
6
6
  import { MetricField, Section, SelectField } from "./propertyPanelPrimitives";
7
7
  import { ColorField } from "./propertyPanelColor";
8
8
  import { FontFamilyField } from "./propertyPanelFont";
9
+ import { PromotableControl } from "./PromotableControl";
9
10
 
10
11
  /* ------------------------------------------------------------------ */
11
12
  /* Text helpers (used only by text section components) */
@@ -280,20 +281,31 @@ function TextFieldEditor({
280
281
  </button>
281
282
  )}
282
283
  </div>
283
- <TextAreaField
284
- key={field.key}
285
- label="Content"
286
- value={field.value}
287
- disabled={false}
288
- autoFocus={showRemove}
289
- onCommit={(next) => onSetText(next, field.key)}
290
- />
291
- <ColorField
292
- label="Text color"
293
- value={getTextFieldColor(field, styles)}
294
- disabled={false}
295
- onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
296
- />
284
+ <PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
285
+ {({ value, onCommit }) => (
286
+ <TextAreaField
287
+ key={field.key}
288
+ label="Content"
289
+ value={value ?? field.value}
290
+ disabled={false}
291
+ autoFocus={showRemove}
292
+ onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
293
+ />
294
+ )}
295
+ </PromotableControl>
296
+ <PromotableControl
297
+ channel={{ kind: "style", prop: "color" }}
298
+ enabled={field.source === "self"}
299
+ >
300
+ {({ value, onCommit }) => (
301
+ <ColorField
302
+ label="Text color"
303
+ value={value ?? getTextFieldColor(field, styles)}
304
+ disabled={false}
305
+ onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
306
+ />
307
+ )}
308
+ </PromotableControl>
297
309
  <div className={RESPONSIVE_GRID}>
298
310
  <MetricField
299
311
  label="Size"
@@ -309,13 +321,22 @@ function TextFieldEditor({
309
321
  onCommit={(next) => onSetTextFieldStyle(field.key, "font-weight", next)}
310
322
  />
311
323
  </div>
312
- <FontFamilyField
313
- value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
314
- disabled={false}
315
- importedFonts={fontAssets}
316
- onImportFonts={onImportFonts}
317
- onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
318
- />
324
+ <PromotableControl
325
+ channel={{ kind: "style", prop: "font-family" }}
326
+ enabled={field.source === "self"}
327
+ >
328
+ {({ value, onCommit }) => (
329
+ <FontFamilyField
330
+ value={
331
+ value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
332
+ }
333
+ disabled={false}
334
+ importedFonts={fontAssets}
335
+ onImportFonts={onImportFonts}
336
+ onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
337
+ />
338
+ )}
339
+ </PromotableControl>
319
340
  <AdvancedTextControls
320
341
  field={field}
321
342
  inheritedStyles={styles}