@hyperframes/studio 0.8.102 → 0.8.103

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 (44) hide show
  1. package/dist/assets/{hyperframes-player-BuRiFtrF.js → hyperframes-player-DG795Hwu.js} +1 -1
  2. package/dist/assets/{index-C2utDZt-.js → index-BPJRf2X9.js} +118 -118
  3. package/dist/assets/{index-BT3sauee.js → index-BiEtDkae.js} +1 -1
  4. package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
  5. package/dist/assets/{index-BM35fjFf.js → index-DuCZ3pQw.js} +1 -1
  6. package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
  7. package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
  8. package/dist/chunk-DI4LCJHL.js.map +1 -0
  9. package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
  10. package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +122 -84
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/DomEditOverlay.tsx +2 -1
  19. package/src/components/editor/PropertyPanel.tsx +2 -2
  20. package/src/components/editor/domEditOverlayGestures.ts +1 -1
  21. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  22. package/src/components/editor/manualOffsetDrag.test.ts +16 -0
  23. package/src/components/editor/manualOffsetDrag.ts +5 -5
  24. package/src/components/editor/plainTranslate.test.ts +38 -22
  25. package/src/components/editor/plainTranslate.ts +66 -36
  26. package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
  27. package/src/components/editor/previewReadOnlyContext.tsx +3 -1
  28. package/src/components/editor/propertyPanelTransformCommit.test.ts +51 -0
  29. package/src/components/editor/propertyPanelTransformCommit.ts +3 -1
  30. package/src/components/editor/useColorGradingController.test.ts +36 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -2
  32. package/src/components/editor/useDomEditNudge.test.tsx +1 -0
  33. package/src/components/editor/useDomEditNudge.ts +6 -3
  34. package/src/hooks/usePreviewInteraction.test.ts +32 -0
  35. package/src/hooks/usePreviewInteraction.ts +3 -2
  36. package/src/player/components/TimelineClipFades.test.tsx +103 -3
  37. package/src/player/components/TimelineClipFades.tsx +53 -50
  38. package/src/styles/theme.css +1 -1
  39. package/src/utils/projectRouting.test.ts +9 -0
  40. package/src/utils/projectRouting.ts +3 -1
  41. package/dist/chunk-S65AQH6M.js.map +0 -1
  42. /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
  43. /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
  44. /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
@@ -267,3 +267,35 @@ describe("usePreviewInteraction", () => {
267
267
  cleanup();
268
268
  });
269
269
  });
270
+
271
+ describe("a refused move's toast", () => {
272
+ it("gives the refusal's own reason first, else the selection's", () => {
273
+ const showToast = vi.fn();
274
+ let blocked!: (selection: DomEditSelection, reason?: string) => void;
275
+ function Harness() {
276
+ blocked = usePreviewInteraction({
277
+ captionEditMode: false,
278
+ compositionLoading: false,
279
+ previewIframeRef: { current: null },
280
+ showToast,
281
+ applyDomSelection: vi.fn(),
282
+ resolveDomSelectionFromPreviewPoint: vi.fn(async () => null),
283
+ resolveAllDomSelectionsFromPreviewPoint: vi.fn(async () => []),
284
+ updateDomEditHoverSelection: vi.fn(),
285
+ setActiveGroupElement: vi.fn(),
286
+ }).handleBlockedDomMove;
287
+ return null;
288
+ }
289
+ const root = createRoot(document.createElement("div"));
290
+ act(() => root.render(React.createElement(Harness)));
291
+ const selection = makeSelection("Box", document.createElement("div"));
292
+ selection.capabilities.reasonIfDisabled = "Locked layer.";
293
+ blocked(selection, "Studio can't read it.");
294
+ blocked(selection);
295
+ act(() => root.unmount());
296
+ expect(showToast.mock.calls).toEqual([
297
+ ["Studio can't read it.", "info"],
298
+ ["Locked layer.", "info"],
299
+ ]);
300
+ });
301
+ });
@@ -260,9 +260,10 @@ export function usePreviewInteraction({
260
260
  }, [updateDomEditHoverSelection]);
261
261
 
262
262
  const handleBlockedDomMove = useCallback(
263
- (selection: DomEditSelection) => {
263
+ (selection: DomEditSelection, reason?: string) => {
264
264
  showToast(
265
- selection.capabilities.reasonIfDisabled ??
265
+ reason ??
266
+ selection.capabilities.reasonIfDisabled ??
266
267
  "This element can't be adjusted directly from the preview.",
267
268
  "info",
268
269
  );
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { createRoot } from "react-dom/client";
4
+ import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { TimelineElement } from "../store/playerStore";
7
7
  import { TimelineClipFades } from "./TimelineClipFades";
@@ -9,7 +9,10 @@ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
9
9
 
10
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
11
 
12
+ const mounted: Root[] = [];
13
+
12
14
  afterEach(() => {
15
+ act(() => mounted.splice(0).forEach((root) => root.unmount()));
13
16
  document.body.innerHTML = "";
14
17
  });
15
18
 
@@ -31,6 +34,7 @@ function render(
31
34
  const host = document.createElement("div");
32
35
  document.body.append(host);
33
36
  const root = createRoot(host);
37
+ mounted.push(root);
34
38
  const onSetElementAttributeLive = vi.fn();
35
39
  const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
36
40
  const onRevertElementAttributeLive = vi.fn();
@@ -97,6 +101,14 @@ describe("TimelineClipFades", () => {
97
101
  act(() => root.unmount());
98
102
  });
99
103
 
104
+ it("clips the ramps to the clip's rounded corners", () => {
105
+ const { host, root } = render(clip, { showHandles: false, provide: false });
106
+ const ramps = host.querySelector<SVGElement>('[data-testid="clip-fade-ramps"]');
107
+ expect(ramps?.style.overflow).toBe("hidden");
108
+ expect(ramps?.style.borderRadius).toBe("inherit");
109
+ act(() => root.unmount());
110
+ });
111
+
100
112
  it("renders nothing for a clip without fades when the handles are hidden", () => {
101
113
  const { host, root } = render(
102
114
  { ...clip, fadeIn: undefined, fadeOut: undefined },
@@ -113,7 +125,7 @@ describe("TimelineClipFades", () => {
113
125
  const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
114
126
  if (!handle) throw new Error("expected a fade-in handle");
115
127
  armCapture(handle);
116
- // The handle sits at the 1 s mark (x=100). Drag 150 px right → 2.5 s.
128
+ // 150 px of rightward travel adds 1.5 s to the 1 s fade-in, wherever the press lands.
117
129
  act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
118
130
  act(() => handle.dispatchEvent(pointer("pointermove", 200)));
119
131
  act(() => handle.dispatchEvent(pointer("pointermove", 250)));
@@ -134,7 +146,7 @@ describe("TimelineClipFades", () => {
134
146
  const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
135
147
  if (!handle) throw new Error("expected a fade-out handle");
136
148
  armCapture(handle);
137
- // Pressed at x=1000 with a 2 s fade-out. 300 px left → 5 s.
149
+ // 300 px of leftward travel adds 3 s to the 2 s fade-out, wherever the press lands.
138
150
  act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
139
151
  act(() => handle.dispatchEvent(pointer("pointermove", 700)));
140
152
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
@@ -179,6 +191,30 @@ describe("TimelineClipFades", () => {
179
191
  act(() => root.unmount());
180
192
  });
181
193
 
194
+ it("cancels a drag on Escape pressed while focus is elsewhere, and saves nothing", () => {
195
+ const { host, onSetElementAttributeQuiet, onRevertElementAttributeLive } = render(clip);
196
+ const elsewhere = document.createElement("button");
197
+ document.body.append(elsewhere);
198
+ elsewhere.focus();
199
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
200
+ if (!handle) throw new Error("expected a fade-in handle");
201
+ armCapture(handle);
202
+ act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
203
+ act(() => handle.dispatchEvent(pointer("pointermove", 300)));
204
+ expect(document.activeElement).toBe(elsewhere);
205
+ act(() => {
206
+ document.activeElement?.dispatchEvent(
207
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
208
+ );
209
+ });
210
+ act(() => handle.dispatchEvent(pointer("pointerup", 300)));
211
+ expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
212
+ expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
213
+ expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
214
+ "0,0 100,0 0,100",
215
+ );
216
+ });
217
+
182
218
  it.each([
183
219
  ["above the window", 300, -40],
184
220
  ["on the window's right edge", window.innerWidth, 10],
@@ -200,6 +236,70 @@ describe("TimelineClipFades", () => {
200
236
  act(() => root.unmount());
201
237
  });
202
238
 
239
+ it.each([
240
+ ["in", "Fade in 0.25 s"],
241
+ ["out", "Fade out 0.4 s"],
242
+ ])(
243
+ "gives the fade-%s handle a 24 px target and a tooltip naming its length",
244
+ async (edge, text) => {
245
+ const { host, root } = render({ ...clip, fadeIn: 0.25, fadeOut: 0.4 });
246
+ const handle = host.querySelector<HTMLElement>(`[data-testid="clip-fade-handle-${edge}"]`);
247
+ if (!handle) throw new Error(`expected a fade-${edge} handle`);
248
+ expect(parseFloat(handle.style.width)).toBeGreaterThanOrEqual(24);
249
+ expect(parseFloat(handle.style.height)).toBeGreaterThanOrEqual(24);
250
+ expect(handle.getAttribute("title")).toBe("");
251
+ act(() => handle.focus());
252
+ await act(async () => {
253
+ await new Promise((resolve) => setTimeout(resolve, 0));
254
+ });
255
+ expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(text);
256
+ act(() => root.unmount());
257
+ },
258
+ );
259
+
260
+ it.each([0.3, 3])("pins the handles to the top corners for a %s s fade-in", (fadeIn) => {
261
+ const { host } = render({ ...clip, fadeIn, fadeOut: 0.4 });
262
+ const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
263
+ const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
264
+ expect(inHandle?.style.left).toBe("0px");
265
+ expect(fadeOut?.style.left).toBe(`${1000 - 24}px`);
266
+ });
267
+
268
+ it("lengthens a fade by the pointer's inward travel from wherever the press lands", () => {
269
+ const { host, onSetElementAttributeLive } = render(clip);
270
+ const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
271
+ const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
272
+ if (!fadeIn || !fadeOut) throw new Error("expected both handles");
273
+ armCapture(fadeIn);
274
+ armCapture(fadeOut);
275
+ act(() => fadeIn.dispatchEvent(pointer("pointerdown", 12)));
276
+ act(() => fadeIn.dispatchEvent(pointer("pointermove", 162)));
277
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
278
+ act(() => fadeIn.dispatchEvent(pointer("pointerup", 162)));
279
+ act(() => fadeOut.dispatchEvent(pointer("pointerdown", 988, 2)));
280
+ act(() => fadeOut.dispatchEvent(pointer("pointermove", 888, 2)));
281
+ expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "3");
282
+ });
283
+
284
+ it("paints the handles above the clip's waveform and thumbnail layers", () => {
285
+ const { host } = render(clip);
286
+ const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
287
+ expect(Number(handle?.style.zIndex)).toBeGreaterThan(10);
288
+ });
289
+
290
+ it("splits a clip narrower than two targets between the handles", () => {
291
+ const { host } = render({ ...clip, duration: 0.3, fadeIn: 0.1, fadeOut: 0.1 });
292
+ const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
293
+ const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
294
+ expect([fadeIn?.style.left, fadeIn?.style.width]).toEqual(["0px", "15px"]);
295
+ expect([fadeOut?.style.left, fadeOut?.style.width]).toEqual(["15px", "15px"]);
296
+ const dot = fadeIn?.firstElementChild as HTMLElement;
297
+ expect(getComputedStyle(dot).width).toBe("10px");
298
+ // Layout is not real here; tests/e2e/fade-handles.mjs measures the box in Chrome.
299
+ expect(getComputedStyle(dot).flexShrink).toBe("0");
300
+ expect(getComputedStyle(dot).pointerEvents).toBe("none");
301
+ });
302
+
203
303
  it("does not let a press on the dot start the clip's own move gesture", () => {
204
304
  const outer = vi.fn();
205
305
  const { host, root } = render(clip, { onClipPointerDown: outer });
@@ -1,11 +1,4 @@
1
- import {
2
- useEffect,
3
- useRef,
4
- useState,
5
- type CSSProperties,
6
- type KeyboardEvent,
7
- type PointerEvent,
8
- } from "react";
1
+ import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
9
2
  import {
10
3
  HF_AUDIO_FADE_IN_ATTR,
11
4
  HF_AUDIO_FADE_OUT_ATTR,
@@ -13,13 +6,16 @@ import {
13
6
  formatFadeSeconds,
14
7
  } from "@hyperframes/core/audio-fade";
15
8
  import type { TimelineElement } from "../store/playerStore";
9
+ import { Tooltip } from "../../components/ui";
16
10
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
17
11
  import { releasedOutsideWindow } from "./timelinePointerRelease";
18
12
 
19
13
  type FadeEdge = "in" | "out";
20
14
 
21
15
  const HANDLE_SIZE = 10;
22
- const HANDLE_HIT = 16;
16
+ const HANDLE_HIT = 24;
17
+ const HANDLE_Z_ABOVE_CLIP_CONTENT = 30;
18
+ const SUPPRESS_CLIP_NATIVE_TITLE = "";
23
19
  /** Pixels of pointer travel before a press on the handle counts as a drag. */
24
20
  const DRAG_THRESHOLD_PX = 2;
25
21
 
@@ -147,31 +143,38 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
147
143
  );
148
144
  };
149
145
 
150
- const onHandleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
151
- const g = gesture.current;
152
- if (e.key !== "Escape" || !g) return;
153
- e.preventDefault();
154
- gesture.current = null;
155
- setDragging(null);
156
- revertGesture(g);
157
- };
146
+ useEffect(() => {
147
+ if (dragging === null) return;
148
+ const cancelOnWindowEscape = (e: KeyboardEvent) => {
149
+ const g = gesture.current;
150
+ if (e.key !== "Escape" || !g) return;
151
+ e.preventDefault();
152
+ e.stopPropagation();
153
+ gesture.current = null;
154
+ setDragging(null);
155
+ revertGesture(g);
156
+ };
157
+ window.addEventListener("keydown", cancelOnWindowEscape, { capture: true });
158
+ return () => window.removeEventListener("keydown", cancelOnWindowEscape, { capture: true });
159
+ });
158
160
 
159
161
  const showIn = fades.fadeIn > 0;
160
162
  const showOut = fades.fadeOut > 0;
161
163
  const handlesVisible = showHandles || dragging !== null;
162
164
  if (!showIn && !showOut && !handlesVisible) return null;
163
165
 
164
- const handleStyle = (leftPx: number): CSSProperties => ({
166
+ const hitWidth = Math.min(HANDLE_HIT, widthPx / 2);
167
+ const handleStyle = (edge: FadeEdge): CSSProperties => ({
165
168
  position: "absolute",
166
169
  top: -(HANDLE_HIT - HANDLE_SIZE) / 2 + 1,
167
- left: leftPx - HANDLE_HIT / 2,
168
- width: HANDLE_HIT,
170
+ left: edge === "in" ? 0 : widthPx - hitWidth,
171
+ width: hitWidth,
169
172
  height: HANDLE_HIT,
170
173
  display: "flex",
171
174
  alignItems: "center",
172
175
  justifyContent: "center",
173
176
  cursor: "ew-resize",
174
- zIndex: 6,
177
+ zIndex: HANDLE_Z_ABOVE_CLIP_CONTENT,
175
178
  opacity: handlesVisible ? 1 : 0,
176
179
  pointerEvents: handlesVisible && canEdit ? "auto" : "none",
177
180
  touchAction: "none",
@@ -191,7 +194,8 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
191
194
  height: "100%",
192
195
  pointerEvents: "none",
193
196
  zIndex: 5,
194
- overflow: "visible",
197
+ overflow: "hidden",
198
+ borderRadius: "inherit",
195
199
  }}
196
200
  viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
197
201
  preserveAspectRatio="none"
@@ -245,25 +249,23 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
245
249
  direction="in"
246
250
  value={fades.fadeIn}
247
251
  max={Math.max(0, el.duration - fades.fadeOut)}
248
- style={handleStyle(inPx)}
252
+ style={handleStyle("in")}
249
253
  dragging={dragging === "in"}
250
254
  onPointerDown={onHandlePointerDown("in")}
251
255
  onPointerMove={onHandlePointerMove}
252
256
  onPointerUp={(e) => finish(e, false)}
253
257
  onPointerCancel={(e) => finish(e, true)}
254
- onKeyDown={onHandleKeyDown}
255
258
  />
256
259
  <FadeHandle
257
260
  direction="out"
258
261
  value={fades.fadeOut}
259
262
  max={Math.max(0, el.duration - fades.fadeIn)}
260
- style={handleStyle(widthPx - outPx)}
263
+ style={handleStyle("out")}
261
264
  dragging={dragging === "out"}
262
265
  onPointerDown={onHandlePointerDown("out")}
263
266
  onPointerMove={onHandlePointerMove}
264
267
  onPointerUp={(e) => finish(e, false)}
265
268
  onPointerCancel={(e) => finish(e, true)}
266
- onKeyDown={onHandleKeyDown}
267
269
  />
268
270
  </>
269
271
  )}
@@ -281,7 +283,6 @@ function FadeHandle({
281
283
  onPointerMove,
282
284
  onPointerUp,
283
285
  onPointerCancel,
284
- onKeyDown,
285
286
  }: {
286
287
  direction: "in" | "out";
287
288
  value: number;
@@ -292,31 +293,31 @@ function FadeHandle({
292
293
  onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
293
294
  onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
294
295
  onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
295
- onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
296
296
  }) {
297
297
  const label = direction === "in" ? "Fade in" : "Fade out";
298
298
  return (
299
- <div
300
- role="slider"
301
- tabIndex={-1}
302
- aria-label={label}
303
- aria-valuemin={0}
304
- aria-valuemax={max}
305
- aria-valuenow={value}
306
- aria-valuetext={`${formatFadeSeconds(value)}s`}
307
- data-testid={`clip-fade-handle-${direction}`}
308
- title={`${label}: ${formatFadeSeconds(value)}s — drag to change`}
309
- style={style}
310
- onPointerDown={onPointerDown}
311
- onPointerMove={onPointerMove}
312
- onPointerUp={onPointerUp}
313
- onPointerCancel={onPointerCancel}
314
- onKeyDown={onKeyDown}
315
- onClick={(e) => e.stopPropagation()}
316
- onDoubleClick={(e) => e.stopPropagation()}
317
- >
318
- <FadeDot active={dragging} />
319
- </div>
299
+ <Tooltip label={`${label} ${formatFadeSeconds(value)} s`}>
300
+ <div
301
+ role="slider"
302
+ tabIndex={-1}
303
+ aria-label={label}
304
+ aria-valuemin={0}
305
+ aria-valuemax={max}
306
+ aria-valuenow={value}
307
+ aria-valuetext={`${formatFadeSeconds(value)}s`}
308
+ data-testid={`clip-fade-handle-${direction}`}
309
+ title={SUPPRESS_CLIP_NATIVE_TITLE}
310
+ style={style}
311
+ onPointerDown={onPointerDown}
312
+ onPointerMove={onPointerMove}
313
+ onPointerUp={onPointerUp}
314
+ onPointerCancel={onPointerCancel}
315
+ onClick={(e) => e.stopPropagation()}
316
+ onDoubleClick={(e) => e.stopPropagation()}
317
+ >
318
+ <FadeDot active={dragging} />
319
+ </div>
320
+ </Tooltip>
320
321
  );
321
322
  }
322
323
 
@@ -326,10 +327,12 @@ function FadeDot({ active }: { active: boolean }) {
326
327
  aria-hidden="true"
327
328
  style={{
328
329
  display: "block",
330
+ flexShrink: 0,
331
+ pointerEvents: "none",
329
332
  width: HANDLE_SIZE,
330
333
  height: HANDLE_SIZE,
331
334
  borderRadius: "50%",
332
- background: active ? "var(--timeline-fade-dot-active)" : "var(--clip-handle)",
335
+ background: "var(--timeline-fade-dot)",
333
336
  boxShadow: "0 0 0 1.5px var(--timeline-fade-dot-ring)",
334
337
  transform: active ? "scale(1.15)" : undefined,
335
338
  transition: "transform 80ms ease-out",
@@ -114,7 +114,7 @@
114
114
  --timeline-handle: rgba(255, 255, 255, 0.55);
115
115
  /* Audio clip fades: the shaded wedge over the faded gain, and the corner dots. */
116
116
  --timeline-fade-shade: rgba(0, 0, 0, 0.38);
117
- --timeline-fade-dot-active: rgba(255, 255, 255, 1);
117
+ --timeline-fade-dot: #fff;
118
118
  --timeline-fade-dot-ring: rgba(0, 0, 0, 0.55);
119
119
  --timeline-resize-seam: rgba(255, 255, 255, 0.12);
120
120
  --timeline-resize-active: rgba(255, 255, 255, 0.24);
@@ -18,6 +18,15 @@ describe("project routing utilities", () => {
18
18
  },
19
19
  );
20
20
 
21
+ it("opens a folder whose name has a colon, encoded once both ways", () => {
22
+ const id = "Customer story: Northwind";
23
+ expect(encodeProjectId(id)).toBe("Customer%20story%3A%20Northwind");
24
+ expect(buildProjectApiPath(id, "/preview")).toBe(
25
+ "/api/projects/Customer%20story%3A%20Northwind/preview",
26
+ );
27
+ expect(parseProjectIdFromHash(buildProjectHash(id))).toBe(id);
28
+ });
29
+
21
30
  it("decodes project ids from hash routes before building capture URLs", () => {
22
31
  vi.useFakeTimers();
23
32
  vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
@@ -5,13 +5,15 @@ export interface ProjectHashRoute {
5
5
  params: URLSearchParams;
6
6
  }
7
7
 
8
+ const WINDOWS_DRIVE_PREFIX = /^[a-z]:/i;
9
+
8
10
  /** Project names are single path segments, including when received from a hash or server. */
9
11
  export function isValidProjectId(value: string): boolean {
10
12
  return (
11
13
  value.length > 0 &&
12
14
  value !== "." &&
13
15
  value !== ".." &&
14
- !value.includes(":") &&
16
+ !WINDOWS_DRIVE_PREFIX.test(value) &&
15
17
  !value.includes("/") &&
16
18
  !value.includes("\\") &&
17
19
  !Array.from(value).some((char) => char.charCodeAt(0) < 32)
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/utils/sourceScopedSelectorIndex.ts","../src/utils/projectRouting.ts"],"sourcesContent":["/**\n * Occurrence index for a selector within its source document.\n *\n * The preview flattens multiple composition files into one DOM, so a raw\n * `querySelectorAll` index is not a stable source-file identity. Callers supply\n * their existing source resolver; this helper alone owns occurrence scoping.\n */\n\n/** Every element matching one selector, mapped to the source file it belongs to\n * and its occurrence index within that file. One document query fills it. */\ntype SelectorOccurrences = Map<Element, { scope: string; index: number }>;\n\ninterface SelectorIndexPass {\n doc: Document;\n bySelector: Map<string, SelectorOccurrences>;\n}\n\nlet activePass: SelectorIndexPass | null = null;\n\n/**\n * Share one occurrence index per selector across every\n * `getSourceScopedSelectorIndex` call made synchronously inside `run`.\n *\n * Unshared, each call runs its own whole-document `querySelectorAll(selector)`\n * and resolves the source file of every match. A rebuild that walks n elements\n * sharing a selector therefore does n whole-document queries and n² resolver\n * calls — a composition of repeated cards or tiles is exactly that shape. One\n * index per selector makes the same pass linear.\n *\n * Correctness rests on the pass being ONE synchronous walk of a document that\n * does not change under it: nothing invalidates the index, so `run` must not\n * mutate `doc`, and every call inside it must resolve source files the same way\n * (the resolver may be a fresh closure per call, as long as it answers the\n * same). Outside a pass the index is per-call, exactly as before.\n */\nexport function withSelectorIndexPass<T>(doc: Document, run: () => T): T {\n const previous = activePass;\n activePass = { doc, bySelector: new Map() };\n try {\n return run();\n } finally {\n activePass = previous;\n }\n}\n\nfunction buildOccurrences(\n doc: Document,\n selector: string,\n resolveSourceFile: (candidate: Element) => string | undefined,\n): SelectorOccurrences {\n const occurrences: SelectorOccurrences = new Map();\n const counts = new Map<string, number>();\n for (const candidate of doc.querySelectorAll(selector)) {\n const scope = resolveSourceFile(candidate) ?? \"index.html\";\n const index = counts.get(scope) ?? 0;\n counts.set(scope, index + 1);\n occurrences.set(candidate, { scope, index });\n }\n return occurrences;\n}\n\nexport function getSourceScopedSelectorIndex(\n doc: Document,\n el: Element,\n selector: string | undefined,\n sourceFile: string | undefined,\n resolveSourceFile: (candidate: Element) => string | undefined,\n): number | undefined {\n if (!selector || selector.startsWith(\"#\") || selector.startsWith(\"[data-composition-id=\")) {\n return undefined;\n }\n\n try {\n const pass = activePass?.doc === doc ? activePass : null;\n let occurrences = pass?.bySelector.get(selector);\n if (!occurrences) {\n occurrences = buildOccurrences(doc, selector, resolveSourceFile);\n pass?.bySelector.set(selector, occurrences);\n }\n // An element outside the requested scope is not in that scope's occurrence\n // run at all, which is the `indexOf` miss the filtered form returned before.\n const hit = occurrences.get(el);\n return hit && hit.scope === (sourceFile ?? \"index.html\") ? hit.index : undefined;\n } catch {\n return undefined;\n }\n}\n","const PROJECT_HASH_PREFIX = \"#project/\";\n\nexport interface ProjectHashRoute {\n projectId: string;\n params: URLSearchParams;\n}\n\n/** Project names are single path segments, including when received from a hash or server. */\nexport function isValidProjectId(value: string): boolean {\n return (\n value.length > 0 &&\n value !== \".\" &&\n value !== \"..\" &&\n !value.includes(\":\") &&\n !value.includes(\"/\") &&\n !value.includes(\"\\\\\") &&\n !Array.from(value).some((char) => char.charCodeAt(0) < 32)\n );\n}\n\nfunction decodeHashProjectId(value: string): string {\n try {\n return decodeURIComponent(value);\n } catch {\n return value;\n }\n}\n\nfunction normalizeHashParams(\n params?: URLSearchParams | Record<string, string | null | undefined>,\n): URLSearchParams {\n if (!params) return new URLSearchParams();\n if (params instanceof URLSearchParams) return params;\n\n const next = new URLSearchParams();\n for (const [key, value] of Object.entries(params)) {\n if (!key || value == null || value === \"\") continue;\n next.set(key, value);\n }\n return next;\n}\n\nexport function encodeProjectId(projectId: string): string {\n if (!isValidProjectId(projectId)) throw new Error(\"Invalid project ID\");\n return encodeURIComponent(projectId);\n}\n\nexport function buildProjectHash(\n projectId: string,\n params?: URLSearchParams | Record<string, string | null | undefined>,\n): string {\n const search = normalizeHashParams(params).toString();\n return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}${search ? `?${search}` : \"\"}`;\n}\n\nexport function parseProjectHashRoute(hash: string): ProjectHashRoute | null {\n if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;\n\n const route = hash.slice(PROJECT_HASH_PREFIX.length);\n const queryIndex = route.indexOf(\"?\");\n const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;\n if (!encodedProjectId || encodedProjectId.includes(\"/\")) return null;\n\n const projectId = decodeHashProjectId(encodedProjectId);\n if (!isValidProjectId(projectId)) return null;\n\n const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : \"\";\n return {\n projectId,\n params: new URLSearchParams(rawParams),\n };\n}\n\nexport function parseProjectIdFromHash(hash: string): string | null {\n return parseProjectHashRoute(hash)?.projectId ?? null;\n}\n\nexport function buildProjectApiPath(projectId: string, suffix = \"\"): string {\n const normalizedSuffix = suffix && !suffix.startsWith(\"/\") ? `/${suffix}` : suffix;\n return `/api/projects/${encodeProjectId(projectId)}${normalizedSuffix}`;\n}\n"],"mappings":"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","names":[]}