@hyperframes/studio 0.6.109 → 0.6.111

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 (66) hide show
  1. package/dist/assets/hyperframes-player-BRjQ5a5_.js +418 -0
  2. package/dist/assets/index-B7S86vK1.js +370 -0
  3. package/dist/assets/index-DP8pPIk2.css +1 -0
  4. package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
  5. package/dist/assets/{index-CFCe0Xvn.js → index-x0c2-zQN.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +8 -5
  8. package/src/App.tsx +133 -141
  9. package/src/components/StudioHeader.tsx +44 -0
  10. package/src/components/StudioOverlays.tsx +70 -0
  11. package/src/components/editor/SourceEditor.tsx +19 -16
  12. package/src/components/editor/gsapAnimationConstants.ts +24 -0
  13. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  14. package/src/components/editor/manualEditingAvailability.ts +25 -7
  15. package/src/components/storyboard/FramePoster.tsx +56 -0
  16. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  17. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  18. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  19. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  20. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  21. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  22. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  23. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  24. package/src/components/storyboard/StoryboardView.tsx +78 -0
  25. package/src/components/storyboard/frameStatus.ts +36 -0
  26. package/src/contexts/ViewModeContext.tsx +98 -0
  27. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  28. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  29. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  30. package/src/hooks/timelineEditingHelpers.ts +2 -0
  31. package/src/hooks/useAppHotkeys.ts +20 -0
  32. package/src/hooks/useDomEditCommits.ts +43 -14
  33. package/src/hooks/useDomEditSession.test.ts +41 -0
  34. package/src/hooks/useDomEditSession.ts +38 -6
  35. package/src/hooks/useDomGeometryCommits.ts +5 -9
  36. package/src/hooks/useElementLifecycleOps.ts +30 -0
  37. package/src/hooks/useGsapAnimationOps.ts +78 -51
  38. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  39. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  40. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  41. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  42. package/src/hooks/useGsapScriptCommits.ts +95 -40
  43. package/src/hooks/useGsapTweenCache.ts +0 -27
  44. package/src/hooks/useRazorSplit.ts +4 -10
  45. package/src/hooks/useSdkSession.test.ts +12 -0
  46. package/src/hooks/useSdkSession.ts +91 -25
  47. package/src/hooks/useStoryboard.ts +80 -0
  48. package/src/hooks/useTimelineEditing.ts +163 -68
  49. package/src/utils/gsapSoftReload.ts +14 -0
  50. package/src/utils/sdkCutover.gate.test.ts +61 -0
  51. package/src/utils/sdkCutover.test.ts +839 -0
  52. package/src/utils/sdkCutover.ts +465 -0
  53. package/src/utils/sdkOpMapping.ts +43 -0
  54. package/src/utils/sdkResolverShadow.test.ts +366 -0
  55. package/src/utils/sdkResolverShadow.ts +313 -0
  56. package/src/utils/timelineElementSplit.test.ts +61 -1
  57. package/src/utils/timelineElementSplit.ts +18 -0
  58. package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
  59. package/dist/assets/index-BVqybwMG.css +0 -1
  60. package/dist/assets/index-ho_f4axK.js +0 -296
  61. package/src/utils/sdkShadow.test.ts +0 -606
  62. package/src/utils/sdkShadow.ts +0 -517
  63. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  64. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  65. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  66. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -3,11 +3,13 @@ import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
3
3
  import {
4
4
  STUDIO_INSPECTOR_PANELS_ENABLED,
5
5
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
6
+ STUDIO_STORYBOARD_ENABLED,
6
7
  } from "./editor/manualEditingAvailability";
7
8
  import { getHistoryShortcutLabel } from "../utils/studioHelpers";
8
9
  import { useStudioShellContext } from "../contexts/StudioContext";
9
10
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
10
11
  import { useDomEditActionsContext } from "../contexts/DomEditContext";
12
+ import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
11
13
  import { trackStudioEvent } from "../utils/studioTelemetry";
12
14
 
13
15
  export interface StudioHeaderProps {
@@ -141,6 +143,46 @@ function HyperframesLogo() {
141
143
  );
142
144
  }
143
145
 
146
+ const VIEW_MODE_OPTIONS: Array<{ mode: StudioViewMode; label: string }> = [
147
+ { mode: "storyboard", label: "Storyboard" },
148
+ { mode: "timeline", label: "Preview" },
149
+ ];
150
+
151
+ /** Segmented control switching the main stage between storyboard and preview. */
152
+ function ViewModeToggle() {
153
+ const { viewMode, setViewMode } = useViewMode();
154
+ return (
155
+ <div
156
+ className="flex items-center gap-0.5 rounded-md bg-neutral-800 p-0.5"
157
+ role="tablist"
158
+ aria-label="Studio view"
159
+ >
160
+ {VIEW_MODE_OPTIONS.map(({ mode, label }) => {
161
+ const active = viewMode === mode;
162
+ return (
163
+ <button
164
+ key={mode}
165
+ type="button"
166
+ role="tab"
167
+ aria-selected={active}
168
+ onClick={() => {
169
+ if (active) return;
170
+ trackStudioEvent("view_mode_toggle", { mode });
171
+ setViewMode(mode);
172
+ }}
173
+ className={`rounded px-3 py-1 text-[11px] font-medium transition-colors ${
174
+ active ? "bg-neutral-200 text-neutral-900" : "text-neutral-400 hover:text-neutral-200"
175
+ }`}
176
+ >
177
+ {label}
178
+ </button>
179
+ );
180
+ })}
181
+ </div>
182
+ );
183
+ }
184
+
185
+ // fallow-ignore-next-line complexity
144
186
  export function StudioHeader({
145
187
  captureFrameHref,
146
188
  captureFrameFilename,
@@ -164,6 +206,8 @@ export function StudioHeader({
164
206
  </span>
165
207
  <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
166
208
  </div>
209
+ {/* Center: storyboard / preview toggle (flag-gated) */}
210
+ {STUDIO_STORYBOARD_ENABLED && <ViewModeToggle />}
167
211
  {/* Right: toolbar buttons */}
168
212
  <div className="flex items-center gap-1.5">
169
213
  <button
@@ -0,0 +1,70 @@
1
+ import type { ComponentProps } from "react";
2
+ import { LintModal } from "./LintModal";
3
+ import { AskAgentModal } from "./AskAgentModal";
4
+ import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
5
+ import { StudioToast } from "./StudioToast";
6
+ import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
7
+ import type { useDomEditSession } from "../hooks/useDomEditSession";
8
+ import type { useToast } from "../hooks/useToast";
9
+
10
+ type LintFindings = ComponentProps<typeof LintModal>["findings"];
11
+
12
+ export interface StudioOverlaysProps {
13
+ projectId: string;
14
+ lintModal: LintFindings | null;
15
+ closeLintModal: () => void;
16
+ consoleErrors: LintFindings | null;
17
+ clearConsoleErrors: () => void;
18
+ domEditSession: ReturnType<typeof useDomEditSession>;
19
+ activeCompPath: string | null;
20
+ dragOverlayActive: boolean;
21
+ appToast: ReturnType<typeof useToast>["appToast"];
22
+ dismissToast: () => void;
23
+ }
24
+
25
+ /**
26
+ * Floating overlays for the studio shell: lint / console-error modals, the
27
+ * ask-agent modal, the global drag overlay, and the toast. Extracted from
28
+ * `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
29
+ */
30
+ // fallow-ignore-next-line complexity
31
+ export function StudioOverlays({
32
+ projectId,
33
+ lintModal,
34
+ closeLintModal,
35
+ consoleErrors,
36
+ clearConsoleErrors,
37
+ domEditSession,
38
+ activeCompPath,
39
+ dragOverlayActive,
40
+ appToast,
41
+ dismissToast,
42
+ }: StudioOverlaysProps) {
43
+ return (
44
+ <>
45
+ {lintModal !== null && (
46
+ <LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
47
+ )}
48
+ {consoleErrors !== null && consoleErrors.length > 0 && (
49
+ <LintModal findings={consoleErrors} projectId={projectId} onClose={clearConsoleErrors} />
50
+ )}
51
+ {domEditSession.agentModalOpen && domEditSession.domEditSelection && (
52
+ <AskAgentModal
53
+ selectionLabel={domEditSession.domEditSelection.label}
54
+ contextPreview={buildAgentContextPreview(domEditSession.domEditSelection, activeCompPath)}
55
+ anchorPoint={domEditSession.agentModalAnchorPoint}
56
+ onSubmit={domEditSession.handleAgentModalSubmit}
57
+ onClose={() => {
58
+ domEditSession.setAgentModalOpen(false);
59
+ domEditSession.setAgentPromptSelectionContext(undefined);
60
+ domEditSession.setAgentModalAnchorPoint(null);
61
+ }}
62
+ />
63
+ )}
64
+ {dragOverlayActive && <StudioGlobalDragOverlay />}
65
+ {appToast && (
66
+ <StudioToast message={appToast.message} tone={appToast.tone} onDismiss={dismissToast} />
67
+ )}
68
+ </>
69
+ );
70
+ }
@@ -12,26 +12,26 @@ import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language
12
12
  import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
13
13
  import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
14
14
  import { oneDark } from "@codemirror/theme-one-dark";
15
+ import type { Extension } from "@codemirror/state";
15
16
  import { html } from "@codemirror/lang-html";
16
17
  import { css } from "@codemirror/lang-css";
17
18
  import { javascript } from "@codemirror/lang-javascript";
19
+ import { markdown } from "@codemirror/lang-markdown";
18
20
 
19
- function getLanguageExtension(language: string) {
20
- switch (language) {
21
- case "html":
22
- return html();
23
- case "css":
24
- return css();
25
- case "javascript":
26
- case "js":
27
- case "typescript":
28
- case "ts":
29
- return javascript({
30
- typescript: language === "typescript" || language === "ts",
31
- });
32
- default:
33
- return html();
34
- }
21
+ const LANGUAGE_EXTENSIONS: Record<string, () => Extension> = {
22
+ html: () => html(),
23
+ css: () => css(),
24
+ markdown: () => markdown(),
25
+ md: () => markdown(),
26
+ javascript: () => javascript(),
27
+ js: () => javascript(),
28
+ typescript: () => javascript({ typescript: true }),
29
+ ts: () => javascript({ typescript: true }),
30
+ };
31
+
32
+ function getLanguageExtension(language: string): Extension {
33
+ const factory = LANGUAGE_EXTENSIONS[language] ?? html;
34
+ return factory();
35
35
  }
36
36
 
37
37
  function detectLanguage(filePath: string): string {
@@ -45,6 +45,8 @@ function detectLanguage(filePath: string): string {
45
45
  jsx: "javascript",
46
46
  tsx: "typescript",
47
47
  json: "javascript",
48
+ md: "markdown",
49
+ markdown: "markdown",
48
50
  };
49
51
  return map[ext] ?? "html";
50
52
  }
@@ -74,6 +76,7 @@ export const SourceEditor = memo(function SourceEditor({
74
76
  const contentRef = useRef(content);
75
77
  contentRef.current = content;
76
78
 
79
+ // fallow-ignore-next-line complexity
77
80
  const mountEditor = useCallback(
78
81
  (node: HTMLDivElement | null) => {
79
82
  if (editorRef.current) {
@@ -20,6 +20,12 @@ export const PROP_LABELS: Record<string, string> = {
20
20
  width: "Width",
21
21
  height: "Height",
22
22
  rotation: "Rotate",
23
+ z: "Move Z",
24
+ rotationX: "Rotate X",
25
+ rotationY: "Rotate Y",
26
+ rotationZ: "Rotate Z",
27
+ perspective: "Perspective",
28
+ transformOrigin: "Transform Origin",
23
29
  opacity: "Opacity",
24
30
  scale: "Scale",
25
31
  scaleX: "Scale X",
@@ -46,6 +52,12 @@ export const PROP_UNITS: Record<string, string> = {
46
52
  width: "px",
47
53
  height: "px",
48
54
  rotation: "°",
55
+ z: "px",
56
+ rotationX: "°",
57
+ rotationY: "°",
58
+ rotationZ: "°",
59
+ perspective: "px",
60
+ transformOrigin: "",
49
61
  opacity: "%",
50
62
  scale: "×",
51
63
  scaleX: "×",
@@ -62,6 +74,13 @@ export const PROP_TOOLTIPS: Record<string, string> = {
62
74
  scaleX: "Horizontal stretch (1 = normal)",
63
75
  scaleY: "Vertical stretch (1 = normal)",
64
76
  rotation: "Spin angle (360 = full rotation)",
77
+ z: "Move forward/back along the Z axis",
78
+ rotationX: "Rotate around the horizontal X axis",
79
+ rotationY: "Rotate around the vertical Y axis",
80
+ rotationZ: "Rotate around the screen-facing Z axis",
81
+ perspective:
82
+ "3D depth context for child elements; set it on a parent when rotating children in 3D",
83
+ transformOrigin: "Pivot point for transforms, for example center center or 50% 50%",
65
84
  width: "Element width",
66
85
  height: "Element height",
67
86
  autoAlpha: "Like opacity but hides element completely at 0",
@@ -147,6 +166,11 @@ export const PROP_CONSTRAINTS: Record<string, { min?: number; max?: number; step
147
166
  scaleX: { min: -10, max: 10, step: 0.01 },
148
167
  scaleY: { min: -10, max: 10, step: 0.01 },
149
168
  rotation: { step: 1 },
169
+ z: { step: 1 },
170
+ rotationX: { step: 1 },
171
+ rotationY: { step: 1 },
172
+ rotationZ: { step: 1 },
173
+ perspective: { min: 0, step: 1 },
150
174
  skewX: { min: -90, max: 90, step: 1 },
151
175
  skewY: { min: -90, max: 90, step: 1 },
152
176
  width: { min: 0, step: 1 },
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
2
3
  import { buildTweenSummary } from "./gsapAnimationHelpers";
4
+ import { PROP_LABELS } from "./gsapAnimationConstants";
3
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
6
 
5
7
  function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
@@ -23,6 +25,27 @@ describe("buildTweenSummary", () => {
23
25
  expect(s).toContain("move x");
24
26
  });
25
27
 
28
+ it("describes 3D transform tweens with labels and units", () => {
29
+ const s = buildTweenSummary(
30
+ anim({
31
+ properties: {
32
+ z: 120,
33
+ rotationX: 45,
34
+ rotationY: -30,
35
+ rotationZ: 90,
36
+ perspective: 800,
37
+ transformOrigin: "50% 50%",
38
+ },
39
+ }),
40
+ );
41
+ expect(s).toContain("move z to 120px");
42
+ expect(s).toContain("rotate x to 45°");
43
+ expect(s).toContain("rotate y to -30°");
44
+ expect(s).toContain("rotate z to 90°");
45
+ expect(s).toContain("perspective to 800px");
46
+ expect(s).toContain("transform origin to 50% 50%");
47
+ });
48
+
26
49
  it("describes a from tween", () => {
27
50
  const s = buildTweenSummary(anim({ method: "from", properties: { opacity: 0 } }));
28
51
  expect(s).toContain("enters from");
@@ -65,3 +88,9 @@ describe("buildTweenSummary", () => {
65
88
  expect(s).toContain("no properties yet");
66
89
  });
67
90
  });
91
+
92
+ describe("PROP_LABELS", () => {
93
+ it("provides labels for every inspector-supported GSAP property", () => {
94
+ expect(SUPPORTED_PROPS.filter((prop) => !PROP_LABELS[prop])).toEqual([]);
95
+ });
96
+ });
@@ -82,6 +82,16 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
82
82
  true,
83
83
  );
84
84
 
85
+ // Storyboard view: a top-level, toggleable view that renders STORYBOARD.md as a
86
+ // contact sheet of live HTML frame tiles, replacing the timeline/preview stage.
87
+ // Opt-in / off by default until the experience is ready for broad exposure.
88
+ // VITE_STUDIO_ENABLE_STORYBOARD=1 npx hyperframes preview
89
+ export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
90
+ env,
91
+ ["VITE_STUDIO_ENABLE_STORYBOARD", "VITE_STUDIO_STORYBOARD_ENABLED"],
92
+ false,
93
+ );
94
+
85
95
  // When disabled (the default), drag/resize/rotate commits always take the CSS
86
96
  // persist path instead of being intercepted into GSAP script keyframe
87
97
  // mutations. The keyframe intercept rewrites timeline tweens from drag
@@ -94,14 +104,22 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
94
104
 
95
105
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
96
106
 
97
- // Stage 7 Step 3b: shadow dispatch parity mode dispatches ops to the SDK
98
- // session alongside the server patch path and logs mismatches via telemetry.
99
- // Default on: server stays authoritative (no user-visible change), so we want
100
- // the sdk_shadow_dispatch parity signal from all traffic. Disable via
101
- // VITE_STUDIO_SDK_SHADOW_ENABLED=false.
102
- export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
107
+ // Stage 7 Step 3c: SDK cutover routes inline-style ops through SDK dispatch
108
+ // instead of the server patch-element API. Default false; enable via
109
+ // VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
110
+ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
111
+ env,
112
+ ["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
113
+ false,
114
+ );
115
+
116
+ // Resolver-parity tripwire (telemetry-only, decoupled from cutover).
117
+ // Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on
118
+ // divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false.
119
+ // Soak gate: retire once zero element_not_found divergences over a clean window.
120
+ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
103
121
  env,
104
- ["VITE_STUDIO_SDK_SHADOW_ENABLED"],
122
+ ["VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED"],
105
123
  true,
106
124
  );
107
125
 
@@ -0,0 +1,56 @@
1
+ import { useEffect, useState } from "react";
2
+ import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
3
+
4
+ export interface FramePosterProps {
5
+ projectId: string;
6
+ /** Project-relative path to the frame's HTML sub-composition. */
7
+ src: string;
8
+ /** Time (seconds) to seek to for the poster. */
9
+ seconds: number;
10
+ title: string;
11
+ /** `cover` fills+crops (contact-sheet tile); `contain` letterboxes (focus hero). */
12
+ fit?: "cover" | "contain";
13
+ }
14
+
15
+ /**
16
+ * Server-rendered poster for a frame. The thumbnail route seeks the composition
17
+ * by time (at its real fps) and caches the result, so there's no live iframe,
18
+ * no postMessage seek, and no client-side fps assumption. Shared by the
19
+ * contact-sheet tile and the frame-focus view.
20
+ */
21
+ export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
22
+ const [failed, setFailed] = useState(false);
23
+ // The <img> is reused (no key) when a tile/hero swaps to a different frame, so a
24
+ // prior load error would stick. Reset when the poster target changes.
25
+ useEffect(() => setFailed(false), [src, seconds]);
26
+ if (failed) {
27
+ return (
28
+ <div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
29
+ Preview unavailable
30
+ </div>
31
+ );
32
+ }
33
+ const url = buildCompositionThumbnailUrl({
34
+ previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
35
+ seekTime: seconds,
36
+ duration: 0,
37
+ origin: window.location.origin,
38
+ });
39
+ return (
40
+ <img
41
+ src={url}
42
+ alt={title}
43
+ draggable={false}
44
+ loading="lazy"
45
+ onError={() => setFailed(true)}
46
+ className={`h-full w-full ${fit === "contain" ? "object-contain" : "object-cover"}`}
47
+ />
48
+ );
49
+ }
50
+
51
+ /** Time (seconds) to show a frame at — past the intro so the key moment is visible. */
52
+ export function posterTime(frame: { poster?: number; durationSeconds?: number }): number {
53
+ if (frame.poster != null) return frame.poster;
54
+ if (frame.durationSeconds != null) return frame.durationSeconds * 0.66;
55
+ return 1.5;
56
+ }
@@ -0,0 +1,45 @@
1
+ import type { StoryboardGlobals } from "@hyperframes/core/storyboard";
2
+
3
+ export interface StoryboardDirectionProps {
4
+ globals: StoryboardGlobals;
5
+ frameCount: number;
6
+ }
7
+
8
+ /**
9
+ * Global direction header: the message/thesis plus arc, audience, and format.
10
+ * This is the storyboard's "north star" pulled from the manifest frontmatter.
11
+ */
12
+ export function StoryboardDirection({ globals, frameCount }: StoryboardDirectionProps) {
13
+ const meta = [
14
+ { label: "Arc", value: globals.arc },
15
+ { label: "Audience", value: globals.audience },
16
+ { label: "Voice", value: globals.extra.voice },
17
+ { label: "Format", value: globals.format },
18
+ { label: "Frames", value: String(frameCount) },
19
+ ].filter((item): item is { label: string; value: string } => Boolean(item.value));
20
+
21
+ return (
22
+ <header className="border-b border-neutral-800 pb-5">
23
+ <div className="text-[11px] font-medium uppercase tracking-wider text-neutral-500">
24
+ Storyboard
25
+ </div>
26
+ {globals.message ? (
27
+ <h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-100">
28
+ {globals.message}
29
+ </h1>
30
+ ) : (
31
+ <h1 className="mt-1 text-2xl font-semibold leading-tight text-neutral-400">
32
+ Untitled storyboard
33
+ </h1>
34
+ )}
35
+ <dl className="mt-4 flex flex-wrap gap-x-8 gap-y-2">
36
+ {meta.map((item) => (
37
+ <div key={item.label} className="flex items-baseline gap-2">
38
+ <dt className="text-[11px] uppercase tracking-wider text-neutral-500">{item.label}</dt>
39
+ <dd className="text-sm text-neutral-300">{item.value}</dd>
40
+ </div>
41
+ ))}
42
+ </dl>
43
+ </header>
44
+ );
45
+ }