@hyperframes/studio 0.6.80 → 0.6.82

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.
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-D8oim9P5.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DcyZuBcU.css">
8
+ <script type="module" crossorigin src="/assets/index-l2BH41kD.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DHcptK1_.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.80",
3
+ "version": "0.6.82",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/core": "0.6.80",
35
- "@hyperframes/player": "0.6.80"
34
+ "@hyperframes/player": "0.6.82",
35
+ "@hyperframes/core": "0.6.82"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.80"
49
+ "@hyperframes/producer": "0.6.82"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
@@ -111,15 +111,22 @@ export const COMMON_LOCAL_FONT_FAMILIES = [
111
111
  "SF Pro Text",
112
112
  "Avenir",
113
113
  "Avenir Next",
114
- "Helvetica Neue",
115
- "Arial",
116
- "Georgia",
117
- "Times New Roman",
118
114
  "Menlo",
119
115
  "Monaco",
120
- "Courier New",
121
116
  ] as const;
122
117
 
118
+ import { resolveAliasDisplayName } from "@hyperframes/core/fonts/aliases";
119
+
120
+ /**
121
+ * Resolves the render-time canonical font for a local font family name.
122
+ * Derived from the shared FONT_ALIAS_MAP — no hand-curation needed.
123
+ */
124
+ export function renderAliasFor(family: string): string | undefined {
125
+ const display = resolveAliasDisplayName(family);
126
+ if (!display || display.toLowerCase() === family.toLowerCase()) return undefined;
127
+ return display;
128
+ }
129
+
123
130
  export function googleFontStylesheetUrl(family: string): string {
124
131
  const encodedFamily = encodeURIComponent(family.trim()).replace(/%20/g, "+");
125
132
  return `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@300;400;500;600;700;800;900&display=swap`;
@@ -0,0 +1,395 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, it, expect } from "vitest";
4
+ import type { PatchOperation } from "../../utils/sourcePatcher";
5
+ import {
6
+ STUDIO_OFFSET_X_PROP,
7
+ STUDIO_OFFSET_Y_PROP,
8
+ STUDIO_WIDTH_PROP,
9
+ STUDIO_HEIGHT_PROP,
10
+ STUDIO_ROTATION_PROP,
11
+ STUDIO_PATH_OFFSET_ATTR,
12
+ STUDIO_BOX_SIZE_ATTR,
13
+ STUDIO_ROTATION_ATTR,
14
+ STUDIO_ROTATION_DRAFT_ATTR,
15
+ STUDIO_ORIGINAL_TRANSLATE_ATTR,
16
+ STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
17
+ STUDIO_ORIGINAL_WIDTH_ATTR,
18
+ STUDIO_ORIGINAL_HEIGHT_ATTR,
19
+ STUDIO_ORIGINAL_MIN_WIDTH_ATTR,
20
+ STUDIO_ORIGINAL_MIN_HEIGHT_ATTR,
21
+ STUDIO_ORIGINAL_MAX_WIDTH_ATTR,
22
+ STUDIO_ORIGINAL_MAX_HEIGHT_ATTR,
23
+ STUDIO_ORIGINAL_FLEX_BASIS_ATTR,
24
+ STUDIO_ORIGINAL_FLEX_GROW_ATTR,
25
+ STUDIO_ORIGINAL_FLEX_SHRINK_ATTR,
26
+ STUDIO_ORIGINAL_BOX_SIZING_ATTR,
27
+ STUDIO_ORIGINAL_SCALE_ATTR,
28
+ STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR,
29
+ STUDIO_ORIGINAL_DISPLAY_ATTR,
30
+ STUDIO_ORIGINAL_ROTATE_ATTR,
31
+ STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
32
+ STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
33
+ STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
34
+ } from "./manualEditsTypes";
35
+ import {
36
+ STUDIO_MOTION_ATTR,
37
+ STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
38
+ STUDIO_MOTION_ORIGINAL_OPACITY_ATTR,
39
+ STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR,
40
+ } from "./studioMotionTypes";
41
+ import {
42
+ buildPathOffsetPatches,
43
+ buildClearPathOffsetPatches,
44
+ buildBoxSizePatches,
45
+ buildClearBoxSizePatches,
46
+ buildRotationPatches,
47
+ buildClearRotationPatches,
48
+ buildMotionPatches,
49
+ buildClearMotionPatches,
50
+ } from "./manualEditsDomPatches";
51
+
52
+ /* ── helpers ── */
53
+
54
+ function div(): HTMLElement {
55
+ return document.createElement("div");
56
+ }
57
+
58
+ function opKey(op: PatchOperation): string {
59
+ return `${op.type}:${op.property}`;
60
+ }
61
+
62
+ function assertClearCoversKeys(buildOps: PatchOperation[], clearOps: PatchOperation[]): void {
63
+ const clearKeys = new Set(clearOps.map(opKey));
64
+ for (const op of buildOps) {
65
+ expect(clearKeys.has(opKey(op)), `clear missing key "${opKey(op)}"`).toBe(true);
66
+ }
67
+ }
68
+
69
+ /* ── Path offset ─────────────────────────────────────────────────────────── */
70
+
71
+ describe("buildPathOffsetPatches / buildClearPathOffsetPatches", () => {
72
+ function populatedPathEl(): HTMLElement {
73
+ const e = div();
74
+ e.style.setProperty(STUDIO_OFFSET_X_PROP, "10px");
75
+ e.style.setProperty(STUDIO_OFFSET_Y_PROP, "20px");
76
+ e.style.setProperty("translate", "10px 20px");
77
+ e.setAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR, "5px 10px");
78
+ e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, "3px");
79
+ e.style.setProperty("display", "flex");
80
+ e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "block");
81
+ return e;
82
+ }
83
+
84
+ it("populated: captures offset styles, attrs, display, and transform-display marker in declaration order", () => {
85
+ const ops = buildPathOffsetPatches(populatedPathEl());
86
+ expect(ops).toEqual([
87
+ { type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: "10px" },
88
+ { type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: "20px" },
89
+ { type: "inline-style", property: "translate", value: "10px 20px" },
90
+ { type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" },
91
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: "5px 10px" },
92
+ { type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: "3px" },
93
+ { type: "inline-style", property: "display", value: "flex" },
94
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "block" },
95
+ ]);
96
+ });
97
+
98
+ it("empty: bare element yields only the path-offset marker", () => {
99
+ expect(buildPathOffsetPatches(div())).toEqual([
100
+ { type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" },
101
+ ]);
102
+ });
103
+
104
+ it("clear: restores translate from STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR and display from STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR", () => {
105
+ const e = div();
106
+ e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, "5px");
107
+ e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "grid");
108
+ const ops = buildClearPathOffsetPatches(e);
109
+ expect(ops).toEqual([
110
+ { type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null },
111
+ { type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null },
112
+ { type: "inline-style", property: "translate", value: "5px" },
113
+ { type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null },
114
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null },
115
+ { type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null },
116
+ { type: "inline-style", property: "display", value: "grid" },
117
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null },
118
+ ]);
119
+ });
120
+
121
+ it("clear: empty STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR coerces to null (translate not set to empty string)", () => {
122
+ const e = div();
123
+ e.setAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, "");
124
+ const ops = buildClearPathOffsetPatches(e);
125
+ expect(ops.find((o) => o.property === "translate")?.value).toBeNull();
126
+ });
127
+
128
+ it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
129
+ const e = populatedPathEl();
130
+ assertClearCoversKeys(buildPathOffsetPatches(e), buildClearPathOffsetPatches(e));
131
+ });
132
+ });
133
+
134
+ /* ── Box size ────────────────────────────────────────────────────────────── */
135
+
136
+ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => {
137
+ function populatedBoxEl(): HTMLElement {
138
+ const e = div();
139
+ e.style.setProperty(STUDIO_WIDTH_PROP, "300px");
140
+ e.style.setProperty(STUDIO_HEIGHT_PROP, "200px");
141
+ e.style.setProperty("width", "300px");
142
+ e.style.setProperty("height", "200px");
143
+ e.style.setProperty("min-width", "100px");
144
+ e.style.setProperty("min-height", "50px");
145
+ e.style.setProperty("max-width", "500px");
146
+ e.style.setProperty("max-height", "400px");
147
+ e.style.setProperty("flex-basis", "auto");
148
+ e.style.setProperty("flex-grow", "1");
149
+ e.style.setProperty("flex-shrink", "0");
150
+ e.style.setProperty("box-sizing", "border-box");
151
+ e.style.setProperty("scale", "1.5");
152
+ e.style.setProperty("transform-origin", "center");
153
+ e.style.setProperty("display", "block");
154
+ e.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, "250px");
155
+ e.setAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR, "150px");
156
+ e.setAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "0px");
157
+ e.setAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "0px");
158
+ e.setAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "none");
159
+ e.setAttribute(STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "none");
160
+ e.setAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "0px");
161
+ e.setAttribute(STUDIO_ORIGINAL_FLEX_GROW_ATTR, "0");
162
+ e.setAttribute(STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "1");
163
+ e.setAttribute(STUDIO_ORIGINAL_BOX_SIZING_ATTR, "content-box");
164
+ e.setAttribute(STUDIO_ORIGINAL_SCALE_ATTR, "1");
165
+ e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "50% 50%");
166
+ e.setAttribute(STUDIO_ORIGINAL_DISPLAY_ATTR, "flex");
167
+ e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "");
168
+ return e;
169
+ }
170
+
171
+ it("populated: captures studio-width/height, all BOX_SIZE_STYLE_PROPS, marker, and all orig attrs", () => {
172
+ const ops = buildBoxSizePatches(populatedBoxEl());
173
+ expect(ops).toEqual([
174
+ { type: "inline-style", property: STUDIO_WIDTH_PROP, value: "300px" },
175
+ { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: "200px" },
176
+ { type: "inline-style", property: "width", value: "300px" },
177
+ { type: "inline-style", property: "height", value: "200px" },
178
+ { type: "inline-style", property: "min-width", value: "100px" },
179
+ { type: "inline-style", property: "min-height", value: "50px" },
180
+ { type: "inline-style", property: "max-width", value: "500px" },
181
+ { type: "inline-style", property: "max-height", value: "400px" },
182
+ { type: "inline-style", property: "flex-basis", value: "auto" },
183
+ { type: "inline-style", property: "flex-grow", value: "1" },
184
+ { type: "inline-style", property: "flex-shrink", value: "0" },
185
+ { type: "inline-style", property: "box-sizing", value: "border-box" },
186
+ { type: "inline-style", property: "scale", value: "1.5" },
187
+ { type: "inline-style", property: "transform-origin", value: "center" },
188
+ { type: "inline-style", property: "display", value: "block" },
189
+ { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" },
190
+ { type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: "250px" },
191
+ { type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: "150px" },
192
+ { type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: "0px" },
193
+ { type: "attribute", property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, value: "0px" },
194
+ { type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: "none" },
195
+ { type: "attribute", property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, value: "none" },
196
+ { type: "attribute", property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR, value: "0px" },
197
+ { type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: "0" },
198
+ { type: "attribute", property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, value: "1" },
199
+ { type: "attribute", property: STUDIO_ORIGINAL_BOX_SIZING_ATTR, value: "content-box" },
200
+ { type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: "1" },
201
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, value: "50% 50%" },
202
+ { type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: "flex" },
203
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "" },
204
+ ]);
205
+ });
206
+
207
+ it("empty: bare element yields only the box-size marker", () => {
208
+ expect(buildBoxSizePatches(div())).toEqual([
209
+ { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" },
210
+ ]);
211
+ });
212
+
213
+ it("clear(populated): ops follow interleaved restore-then-null order for every orig attr", () => {
214
+ const ops = buildClearBoxSizePatches(populatedBoxEl());
215
+ expect(ops).toEqual([
216
+ { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null },
217
+ { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null },
218
+ { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null },
219
+ { type: "inline-style", property: "width", value: "250px" },
220
+ { type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: null },
221
+ { type: "inline-style", property: "height", value: "150px" },
222
+ { type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: null },
223
+ { type: "inline-style", property: "min-width", value: "0px" },
224
+ { type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: null },
225
+ { type: "inline-style", property: "min-height", value: "0px" },
226
+ { type: "attribute", property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, value: null },
227
+ { type: "inline-style", property: "max-width", value: "none" },
228
+ { type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: null },
229
+ { type: "inline-style", property: "max-height", value: "none" },
230
+ { type: "attribute", property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, value: null },
231
+ { type: "inline-style", property: "flex-basis", value: "0px" },
232
+ { type: "attribute", property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR, value: null },
233
+ { type: "inline-style", property: "flex-grow", value: "0" },
234
+ { type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: null },
235
+ { type: "inline-style", property: "flex-shrink", value: "1" },
236
+ { type: "attribute", property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, value: null },
237
+ { type: "inline-style", property: "box-sizing", value: "content-box" },
238
+ { type: "attribute", property: STUDIO_ORIGINAL_BOX_SIZING_ATTR, value: null },
239
+ { type: "inline-style", property: "scale", value: "1" },
240
+ { type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: null },
241
+ { type: "inline-style", property: "transform-origin", value: "50% 50%" },
242
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, value: null },
243
+ { type: "inline-style", property: "display", value: "flex" },
244
+ { type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: null },
245
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null },
246
+ ]);
247
+ });
248
+
249
+ it("clear: empty orig attr coerces to null (style is removed rather than set to empty string)", () => {
250
+ const e = div();
251
+ e.setAttribute(STUDIO_ORIGINAL_WIDTH_ATTR, "");
252
+ const ops = buildClearBoxSizePatches(e);
253
+ expect(ops.find((o) => o.property === "width")?.value).toBeNull();
254
+ });
255
+
256
+ it("clear: bare element emits only null ops — no style restores fire when orig attrs are absent", () => {
257
+ const ops = buildClearBoxSizePatches(div());
258
+ // 3 fixed (studio-width, studio-height, box-size marker) + 14 attr-null pushes (one per BOX_SIZE_ORIG_ATTR)
259
+ expect(ops).toHaveLength(17);
260
+ expect(ops.every((op) => op.value === null)).toBe(true);
261
+ });
262
+
263
+ it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
264
+ const e = populatedBoxEl();
265
+ assertClearCoversKeys(buildBoxSizePatches(e), buildClearBoxSizePatches(e));
266
+ });
267
+ });
268
+
269
+ /* ── Rotation ────────────────────────────────────────────────────────────── */
270
+
271
+ describe("buildRotationPatches / buildClearRotationPatches", () => {
272
+ function populatedRotEl(): HTMLElement {
273
+ const e = div();
274
+ e.style.setProperty(STUDIO_ROTATION_PROP, "45");
275
+ e.style.setProperty("rotate", "45deg");
276
+ e.style.setProperty("transform-origin", "left center");
277
+ e.style.setProperty("display", "block");
278
+ e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg");
279
+ e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg");
280
+ e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center");
281
+ e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex");
282
+ return e;
283
+ }
284
+
285
+ it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => {
286
+ const ops = buildRotationPatches(populatedRotEl());
287
+ expect(ops).toEqual([
288
+ { type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" },
289
+ { type: "inline-style", property: "rotate", value: "45deg" },
290
+ { type: "inline-style", property: "transform-origin", value: "left center" },
291
+ { type: "inline-style", property: "display", value: "block" },
292
+ { type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
293
+ { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" },
294
+ { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" },
295
+ {
296
+ type: "attribute",
297
+ property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
298
+ value: "center center",
299
+ },
300
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" },
301
+ ]);
302
+ });
303
+
304
+ it("empty: bare element yields only the rotation marker", () => {
305
+ expect(buildRotationPatches(div())).toEqual([
306
+ { type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
307
+ ]);
308
+ });
309
+
310
+ it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => {
311
+ const e = div();
312
+ e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg");
313
+ e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "top left");
314
+ e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "grid");
315
+ const ops = buildClearRotationPatches(e);
316
+ expect(ops).toEqual([
317
+ { type: "inline-style", property: STUDIO_ROTATION_PROP, value: null },
318
+ { type: "inline-style", property: "rotate", value: "30deg" },
319
+ { type: "inline-style", property: "transform-origin", value: "top left" },
320
+ { type: "attribute", property: STUDIO_ROTATION_ATTR, value: null },
321
+ { type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null },
322
+ { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null },
323
+ { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null },
324
+ { type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null },
325
+ { type: "inline-style", property: "display", value: "grid" },
326
+ { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null },
327
+ ]);
328
+ });
329
+
330
+ it("clear: absent STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR yields null for transform-origin", () => {
331
+ const ops = buildClearRotationPatches(div());
332
+ expect(ops.find((o) => o.property === "transform-origin")?.value).toBeNull();
333
+ });
334
+
335
+ it("clear: empty STUDIO_ORIGINAL_INLINE_ROTATE_ATTR coerces to null (rotate not set to empty string)", () => {
336
+ const e = div();
337
+ e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "");
338
+ const ops = buildClearRotationPatches(e);
339
+ expect(ops.find((o) => o.property === "rotate")?.value).toBeNull();
340
+ });
341
+
342
+ it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
343
+ const e = populatedRotEl();
344
+ assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e));
345
+ });
346
+ });
347
+
348
+ /* ── Motion ──────────────────────────────────────────────────────────────── */
349
+
350
+ describe("buildMotionPatches / buildClearMotionPatches", () => {
351
+ const MOTION_JSON = '{"kind":"gsap-motion","start":0,"duration":1}';
352
+
353
+ function populatedMotionEl(): HTMLElement {
354
+ const e = div();
355
+ e.setAttribute(STUDIO_MOTION_ATTR, MOTION_JSON);
356
+ e.setAttribute(STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, "translateX(0)");
357
+ e.setAttribute(STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, "1");
358
+ e.setAttribute(STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, "visible");
359
+ return e;
360
+ }
361
+
362
+ it("populated: captures motion JSON and all three original attrs when motion attr is present", () => {
363
+ const ops = buildMotionPatches(populatedMotionEl());
364
+ expect(ops).toEqual([
365
+ { type: "attribute", property: STUDIO_MOTION_ATTR, value: MOTION_JSON },
366
+ {
367
+ type: "attribute",
368
+ property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR,
369
+ value: "translateX(0)",
370
+ },
371
+ { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: "1" },
372
+ { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: "visible" },
373
+ ]);
374
+ });
375
+
376
+ it("empty: returns [] when STUDIO_MOTION_ATTR is absent", () => {
377
+ expect(buildMotionPatches(div())).toEqual([]);
378
+ });
379
+
380
+ it("clear: always nulls all four motion attrs regardless of element state", () => {
381
+ const expected = [
382
+ { type: "attribute", property: STUDIO_MOTION_ATTR, value: null },
383
+ { type: "attribute", property: STUDIO_MOTION_ORIGINAL_TRANSFORM_ATTR, value: null },
384
+ { type: "attribute", property: STUDIO_MOTION_ORIGINAL_OPACITY_ATTR, value: null },
385
+ { type: "attribute", property: STUDIO_MOTION_ORIGINAL_VISIBILITY_ATTR, value: null },
386
+ ];
387
+ expect(buildClearMotionPatches(div())).toEqual(expected);
388
+ expect(buildClearMotionPatches(populatedMotionEl())).toEqual(expected);
389
+ });
390
+
391
+ it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
392
+ const e = populatedMotionEl();
393
+ assertClearCoversKeys(buildMotionPatches(e), buildClearMotionPatches(e));
394
+ });
395
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * T4 — Op contract stubs.
3
+ *
4
+ * These tests define the expected shape of the Studio editor operation (op) dispatch boundary
5
+ * that will be introduced during R5 (op-shape refactor) and R6 (runtime bridge).
6
+ * All are .todo until the dispatch boundary is exposed.
7
+ */
8
+ import { describe, it } from "vitest";
9
+
10
+ describe("T4 — op contract: move", () => {
11
+ it.todo("move op has { type: 'move', id, x, y } shape");
12
+ it.todo("move op applied to element produces updated left/top style values");
13
+ it.todo("move op is recorded in edit history with label 'Move layer'");
14
+ it.todo("move op coalesces when dragging (same id, within coalesce window)");
15
+ });
16
+
17
+ describe("T4 — op contract: resize", () => {
18
+ it.todo("resize op has { type: 'resize', id, width, height } shape");
19
+ it.todo("resize op applied updates width/height style values");
20
+ it.todo("resize op is recorded in edit history with label 'Resize layer'");
21
+ });
22
+
23
+ describe("T4 — op contract: retime", () => {
24
+ it.todo("retime op has { type: 'retime', id, startTime, duration } shape");
25
+ it.todo("retime op updates data-start/data-end attributes on the element");
26
+ it.todo("retime op is recorded in edit history with label 'Retime layer'");
27
+ });
28
+
29
+ describe("T4 — op contract: style", () => {
30
+ it.todo("style op has { type: 'style', id, prop, value } shape");
31
+ it.todo("style op applied updates the correct inline style property");
32
+ it.todo("style op coalesces same id+prop edits within window");
33
+ });
34
+
35
+ describe("T4 — op contract: dispatch boundary", () => {
36
+ it.todo("dispatch emits origin:'studio' on every op for SDK origin guard");
37
+ it.todo("applyPatches with origin:'applyPatches' does not push to undo stack");
38
+ it.todo("dispatching an unknown op type throws at the boundary, not silently fails");
39
+ });
@@ -1,5 +1,9 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
- import { googleFontStylesheetUrl, POPULAR_GOOGLE_FONT_FAMILIES } from "./fontCatalog";
2
+ import {
3
+ googleFontStylesheetUrl,
4
+ POPULAR_GOOGLE_FONT_FAMILIES,
5
+ renderAliasFor,
6
+ } from "./fontCatalog";
3
7
  import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
4
8
  import {
5
9
  DEFAULT_FONT_FAMILIES,
@@ -315,12 +319,32 @@ export function FontFamilyField({
315
319
  );
316
320
  };
317
321
 
322
+ const importSystemFont = async (family: string): Promise<ImportedFontAsset | null> => {
323
+ if (!onImportFonts) return null;
324
+ const response = await fetch(`/api/fonts/file?family=${encodeURIComponent(family)}`);
325
+ if (!response.ok) return null;
326
+ const blob = await response.blob();
327
+ const ext = response.headers.get("Content-Disposition")?.match(/\.(\w+)"?$/)?.[1] ?? "ttf";
328
+ const file = new File([blob], `${family}.${ext}`, { type: blob.type || "font/ttf" });
329
+ const imported = await onImportFonts([file]);
330
+ return (
331
+ imported.find((a) => a.family.toLowerCase() === family.toLowerCase()) ?? imported[0] ?? null
332
+ );
333
+ };
334
+
318
335
  const commitFamily = async (option: FontOption) => {
319
- if (option.source === "Local") {
336
+ const needsImport =
337
+ option.source === "Local" ||
338
+ (option.source === "System" && !GENERIC_FONT_FAMILIES.has(option.family.toLowerCase()));
339
+
340
+ if (needsImport) {
320
341
  setImportingFonts(true);
321
342
  setFontNotice(null);
322
343
  try {
323
- const imported = await importLocalFont(option.family);
344
+ const imported =
345
+ option.source === "Local"
346
+ ? await importLocalFont(option.family)
347
+ : await importSystemFont(option.family);
324
348
  if (imported) {
325
349
  loadImportedFontStylesheet(imported);
326
350
  onCommit(buildFontFamilyValue(imported.family));
@@ -328,13 +352,9 @@ export function FontFamilyField({
328
352
  setOpen(false);
329
353
  return;
330
354
  }
331
- onCommit(buildFontFamilyValue(option.family));
332
- setQuery("");
333
- setOpen(false);
334
355
  } finally {
335
356
  setImportingFonts(false);
336
357
  }
337
- return;
338
358
  }
339
359
  if (option.source === "Google") loadGoogleFontStylesheet(option.family);
340
360
  const imported = importedFonts.find(
@@ -440,7 +460,14 @@ export function FontFamilyField({
440
460
  : "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
441
461
  }`}
442
462
  >
443
- <span className="min-w-0 truncate font-medium">{option.family}</span>
463
+ <span className="flex min-w-0 items-center gap-1.5">
464
+ <span className="truncate font-medium">{option.family}</span>
465
+ {renderAliasFor(option.family) && (
466
+ <span className="flex-shrink-0 text-[9px] text-neutral-500">
467
+ → {renderAliasFor(option.family)}
468
+ </span>
469
+ )}
470
+ </span>
444
471
  <span className="flex-shrink-0 text-[9px] uppercase tracking-[0.14em] text-neutral-600">
445
472
  {option.source}
446
473
  </span>
@@ -1,6 +1,10 @@
1
1
  import { useState, useEffect, useMemo } from "react";
2
2
  import type { RegistryItem } from "@hyperframes/core/registry";
3
- import { type BlockCategory, resolveBlockCategory } from "../utils/blockCategories";
3
+ import {
4
+ BLOCK_CATEGORIES,
5
+ type BlockCategory,
6
+ resolveBlockCategory,
7
+ } from "../utils/blockCategories";
4
8
 
5
9
  export type CatalogItem = RegistryItem & {
6
10
  category: BlockCategory;
@@ -15,16 +19,6 @@ export function useBlockCatalog() {
15
19
 
16
20
  // fallow-ignore-next-line complexity
17
21
  useEffect(() => {
18
- const CATEGORY_ORDER: Record<BlockCategory, number> = {
19
- captions: 0,
20
- vfx: 1,
21
- transitions: 2,
22
- effects: 3,
23
- social: 4,
24
- data: 5,
25
- scenes: 6,
26
- };
27
-
28
22
  let cancelled = false;
29
23
  (async () => {
30
24
  try {
@@ -34,7 +28,11 @@ export function useBlockCatalog() {
34
28
  if (cancelled) return;
35
29
  const items = data
36
30
  .map((b) => ({ ...b, category: resolveBlockCategory(b.tags) }))
37
- .sort((a, b) => (CATEGORY_ORDER[a.category] ?? 9) - (CATEGORY_ORDER[b.category] ?? 9));
31
+ .sort((a, b) => {
32
+ const ia = BLOCK_CATEGORIES.findIndex((c) => c.id === a.category);
33
+ const ib = BLOCK_CATEGORIES.findIndex((c) => c.id === b.category);
34
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
35
+ });
38
36
  setBlocks(items);
39
37
  } catch (err) {
40
38
  if (cancelled) return;
@@ -16,6 +16,7 @@ const COLOR_MAP: Record<BlockCategory, { bg: string; text: string; dot: string }
16
16
  scenes: { bg: "bg-amber-500/15", text: "text-amber-400", dot: "bg-amber-400" },
17
17
  captions: { bg: "bg-cyan-500/15", text: "text-cyan-400", dot: "bg-cyan-400" },
18
18
  effects: { bg: "bg-rose-500/15", text: "text-rose-400", dot: "bg-rose-400" },
19
+ "text-effects": { bg: "bg-violet-500/15", text: "text-violet-400", dot: "bg-violet-400" },
19
20
  };
20
21
 
21
22
  export function getCategoryColors(category: BlockCategory) {
@@ -516,3 +516,18 @@ describe("motion attribute round-trip via sourcePatcher", () => {
516
516
  expect(JSON.parse(readBack!)).toEqual(motion);
517
517
  });
518
518
  });
519
+
520
+ // T3 — id-based targeting (spec for R1).
521
+ // R1 adds `hfId?: string` to PatchTarget and a `[data-hf-id="…"]` lookup branch
522
+ // in findTagByTarget. Convert from it.todo to real assertions in the R1 PR.
523
+ describe("T3 — hfId targeting (spec for R1)", () => {
524
+ it.todo("updates inline style by data-hf-id");
525
+
526
+ it.todo("updates text content by data-hf-id");
527
+
528
+ it.todo("updates attribute by data-hf-id");
529
+
530
+ it.todo("data-hf-id attribute is preserved after a style patch");
531
+
532
+ it.todo("hfId lookup falls through to selector when hfId not found");
533
+ });