@pixel-point/toolcraft 0.0.3 → 0.0.6

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 (47) hide show
  1. package/package.json +1 -1
  2. package/scripts/prepare-pack.mjs +5 -0
  3. package/src/generate.mjs +13 -0
  4. package/src/generate.test.mjs +6 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +86 -8
  6. package/templates/runtime/contracts/component-contracts.ts +36 -8
  7. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  8. package/templates/runtime/export/export.test.ts +65 -0
  9. package/templates/runtime/export/export.ts +54 -1
  10. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  11. package/templates/runtime/react/controls-panel.test.tsx +323 -6
  12. package/templates/runtime/react/controls-panel.tsx +349 -24
  13. package/templates/runtime/react/settings-transfer.test.ts +6 -0
  14. package/templates/runtime/react/settings-transfer.ts +28 -2
  15. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  16. package/templates/runtime/react/timeline-panel.tsx +98 -10
  17. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  18. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  19. package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
  20. package/templates/runtime/schema/define-toolcraft.test.ts +122 -2
  21. package/templates/runtime/schema/define-toolcraft.ts +197 -6
  22. package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
  23. package/templates/runtime/schema/keyframe-capability.ts +2 -2
  24. package/templates/runtime/schema/runtime-targets.ts +6 -0
  25. package/templates/runtime/schema/types.ts +23 -1
  26. package/templates/runtime/state/canvas-zoom.ts +1 -1
  27. package/templates/runtime/state/create-template-state.test.ts +9 -3
  28. package/templates/runtime/state/reducer.test.ts +135 -2
  29. package/templates/runtime/state/reducer.ts +236 -12
  30. package/templates/runtime/state/types.ts +1 -0
  31. package/templates/starter/AGENTS.md +6 -4
  32. package/templates/starter/docs/toolcraft/README.md +1 -1
  33. package/templates/starter/docs/toolcraft/acceptance-testing.md +4 -2
  34. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -4
  35. package/templates/starter/docs/toolcraft/component-rules.md +24 -5
  36. package/templates/starter/docs/toolcraft/performance.md +5 -0
  37. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  38. package/templates/starter/docs/toolcraft/schema-reference.md +53 -9
  39. package/templates/starter/gitignore +36 -0
  40. package/templates/starter/src/app/starter-acceptance.test.ts +678 -21
  41. package/templates/starter/src/app/starter-acceptance.ts +357 -4
  42. package/templates/ui/components/control-layout/index.tsx +4 -4
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  44. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
  45. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  46. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  47. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
@@ -42,6 +42,7 @@ import {
42
42
  type ColorOpacityValue,
43
43
  type CurveInterpolation,
44
44
  type FontPickerValue,
45
+ type FileDropPreview,
45
46
  type GradientStop,
46
47
  type GradientType,
47
48
  type ImagePickerItem,
@@ -57,6 +58,10 @@ import type {
57
58
  ToolcraftControlSchema,
58
59
  ResolvedToolcraftAppSchema,
59
60
  } from "../schema/types";
61
+ import {
62
+ getToolcraftCanvasAspectRatioPreset,
63
+ toolcraftCanvasAspectRatioPresets,
64
+ } from "../schema/canvas-aspect-ratio-presets";
60
65
  import { getToolcraftControlKeyframeCapability } from "../schema/keyframe-capability";
61
66
  import { getToolcraftCanvasSizeTargetDimension } from "../schema/runtime-targets";
62
67
  import type {
@@ -112,9 +117,22 @@ type FooterActionProgressEntry = {
112
117
  id: number;
113
118
  progress: number | null;
114
119
  };
120
+ type CanvasAspectRatioValue = {
121
+ height: number;
122
+ mode: "custom" | "preset";
123
+ value: string;
124
+ width: number;
125
+ };
115
126
 
116
127
  const hiddenDiscreteMarkerCount = 2;
117
128
  const controlsPanelSectionCollapseStorageVersion = 1;
129
+ const canvasAspectRatioOptions = [
130
+ ...toolcraftCanvasAspectRatioPresets.map((preset) => ({
131
+ label: preset.value,
132
+ value: preset.value,
133
+ })),
134
+ { label: "Custom...", value: "custom" },
135
+ ] as const;
118
136
 
119
137
  const sectionedCompoundControlTypes = new Set([
120
138
  "channelMixer",
@@ -257,6 +275,86 @@ function asString(value: unknown, fallback = ""): string {
257
275
  return typeof value === "number" && Number.isFinite(value) ? String(value) : fallback;
258
276
  }
259
277
 
278
+ function parseCanvasAspectRatioOption(value: string): CanvasAspectRatioValue | null {
279
+ const preset = getToolcraftCanvasAspectRatioPreset(value);
280
+
281
+ if (preset) {
282
+ return {
283
+ height: preset.ratioHeight,
284
+ mode: "preset",
285
+ value: preset.value,
286
+ width: preset.ratioWidth,
287
+ };
288
+ }
289
+
290
+ const match = /^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/u.exec(value);
291
+
292
+ if (!match) {
293
+ return null;
294
+ }
295
+
296
+ const width = Number.parseFloat(match[1] ?? "");
297
+ const height = Number.parseFloat(match[2] ?? "");
298
+
299
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
300
+ return null;
301
+ }
302
+
303
+ return {
304
+ height: Math.round(height),
305
+ mode: "custom",
306
+ value: `${Math.round(width)}:${Math.round(height)}`,
307
+ width: Math.round(width),
308
+ };
309
+ }
310
+
311
+ function asCanvasAspectRatioValue(
312
+ value: unknown,
313
+ fallback: unknown,
314
+ ): CanvasAspectRatioValue {
315
+ if (isRecord(value)) {
316
+ const width = asNumber(value.width, NaN);
317
+ const height = asNumber(value.height, NaN);
318
+ const mode = value.mode === "preset" ? "preset" : "custom";
319
+
320
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
321
+ return {
322
+ height: Math.round(height),
323
+ mode,
324
+ value:
325
+ typeof value.value === "string"
326
+ ? value.value
327
+ : `${Math.round(width)}:${Math.round(height)}`,
328
+ width: Math.round(width),
329
+ };
330
+ }
331
+ }
332
+
333
+ if (typeof value === "string") {
334
+ const parsed = parseCanvasAspectRatioOption(value);
335
+
336
+ if (parsed) {
337
+ return parsed;
338
+ }
339
+ }
340
+
341
+ if (fallback !== value) {
342
+ return asCanvasAspectRatioValue(fallback, {
343
+ height: 1,
344
+ mode: "preset",
345
+ value: "1:1",
346
+ width: 1,
347
+ });
348
+ }
349
+
350
+ return {
351
+ height: 1,
352
+ mode: "preset",
353
+ value: "1:1",
354
+ width: 1,
355
+ };
356
+ }
357
+
260
358
  function asBoolean(value: unknown, fallback = false): boolean {
261
359
  return typeof value === "boolean" ? value : fallback;
262
360
  }
@@ -330,6 +428,8 @@ function formatControlValueLabel(
330
428
  }
331
429
 
332
430
  switch (control.type) {
431
+ case "aspectRatio":
432
+ return asCanvasAspectRatioValue(value, control.defaultValue).value;
333
433
  case "checkbox":
334
434
  case "switch":
335
435
  return asBoolean(value) ? "On" : "Off";
@@ -505,6 +605,111 @@ function asFontPickerValue(value: unknown): FontPickerValue {
505
605
  };
506
606
  }
507
607
 
608
+ function CanvasAspectRatioControl({
609
+ defaultValue,
610
+ name,
611
+ onValueChange,
612
+ value,
613
+ }: {
614
+ defaultValue: unknown;
615
+ name: string;
616
+ onValueChange?: (
617
+ value: CanvasAspectRatioValue,
618
+ meta?: ControlChangeMeta,
619
+ ) => void;
620
+ value: unknown;
621
+ }): React.JSX.Element {
622
+ const ratio = asCanvasAspectRatioValue(value, defaultValue);
623
+ const selectedValue = ratio.mode === "custom" ? "custom" : ratio.value;
624
+
625
+ function commitRatio(
626
+ nextRatio: CanvasAspectRatioValue,
627
+ meta?: ControlChangeMeta,
628
+ ): void {
629
+ onValueChange?.(nextRatio, meta);
630
+ }
631
+
632
+ function updatePreset(nextValue: string): void {
633
+ if (nextValue === "custom") {
634
+ commitRatio({
635
+ height: ratio.height,
636
+ mode: "custom",
637
+ value: `${ratio.width}:${ratio.height}`,
638
+ width: ratio.width,
639
+ });
640
+ return;
641
+ }
642
+
643
+ const nextRatio = parseCanvasAspectRatioOption(nextValue);
644
+
645
+ if (nextRatio) {
646
+ commitRatio(nextRatio);
647
+ }
648
+ }
649
+
650
+ function updateCustomDimension(
651
+ dimension: "height" | "width",
652
+ nextValue: string,
653
+ meta?: ControlChangeMeta,
654
+ ): void {
655
+ const numberValue = Number.parseFloat(nextValue);
656
+
657
+ if (!Number.isFinite(numberValue) || numberValue <= 0) {
658
+ return;
659
+ }
660
+
661
+ const width =
662
+ dimension === "width" ? Math.max(1, Math.round(numberValue)) : ratio.width;
663
+ const height =
664
+ dimension === "height" ? Math.max(1, Math.round(numberValue)) : ratio.height;
665
+
666
+ commitRatio(
667
+ {
668
+ height,
669
+ mode: "custom",
670
+ value: `${width}:${height}`,
671
+ width,
672
+ },
673
+ meta,
674
+ );
675
+ }
676
+
677
+ return (
678
+ <div className="min-w-0 space-y-2" data-slot="canvas-aspect-ratio-control">
679
+ <Select
680
+ layout="stacked"
681
+ name={name}
682
+ onValueChange={updatePreset}
683
+ options={canvasAspectRatioOptions}
684
+ value={selectedValue}
685
+ />
686
+ {ratio.mode === "custom" ? (
687
+ <TextInput
688
+ inputs={[
689
+ {
690
+ commitOnBlur: true,
691
+ defaultValue: String(ratio.width),
692
+ name: "Width",
693
+ onValueChange: (nextValue, meta) =>
694
+ updateCustomDimension("width", nextValue, meta),
695
+ value: String(ratio.width),
696
+ },
697
+ {
698
+ commitOnBlur: true,
699
+ defaultValue: String(ratio.height),
700
+ name: "Height",
701
+ onValueChange: (nextValue, meta) =>
702
+ updateCustomDimension("height", nextValue, meta),
703
+ value: String(ratio.height),
704
+ },
705
+ ]}
706
+ inputsPerRow={2}
707
+ />
708
+ ) : null}
709
+ </div>
710
+ );
711
+ }
712
+
508
713
  function asActionSchemas(
509
714
  actions: readonly (ToolcraftActionSchema | string)[] | undefined,
510
715
  ): readonly ToolcraftActionSchema[] {
@@ -690,6 +895,7 @@ function isRuntimeSetupSection(section: ToolcraftControlSectionSchema): boolean
690
895
  return Object.values(section.controls).some(
691
896
  (control) =>
692
897
  control.type === "settingsTransfer" ||
898
+ control.target === "canvas.aspectRatio" ||
693
899
  control.target === "canvas.size.width" ||
694
900
  control.target === "canvas.size.height",
695
901
  );
@@ -1486,12 +1692,94 @@ export function ControlsPanel({
1486
1692
  );
1487
1693
  }
1488
1694
 
1489
- function getControlHelpText(
1490
- control: ToolcraftControlSchema,
1491
- ): string | null {
1695
+ function normalizeControlHelpContext(value: string): string {
1696
+ return value
1697
+ .toLowerCase()
1698
+ .replace(/[^a-z0-9]+/g, " ")
1699
+ .trim();
1700
+ }
1701
+
1702
+ function isColorSectionTitle(sectionTitle: string | undefined): boolean {
1703
+ return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
1704
+ }
1705
+
1706
+ function isSequentialColorLabel(label: string): boolean {
1707
+ return /^colou?r\s+\d+$/i.test(label.trim());
1708
+ }
1709
+
1710
+ function isSimplePaletteDistributionLabel(label: string): boolean {
1711
+ return /^(spread|mix|distribution)$/i.test(label.trim());
1712
+ }
1713
+
1714
+ function isGenericControlHelpDescription(description: string): boolean {
1715
+ return /^(adjusts?|changes?|chooses?|controls?|defines?|selects?|sets?|updates?)\b/i.test(
1716
+ description.trim(),
1717
+ );
1718
+ }
1719
+
1720
+ function shouldSuppressObviousControlHelp({
1721
+ control,
1722
+ description,
1723
+ label,
1724
+ sectionTitle,
1725
+ }: {
1726
+ control: ToolcraftControlSchema;
1727
+ description: string;
1728
+ label: string;
1729
+ sectionTitle: string | undefined;
1730
+ }): boolean {
1731
+ const isColorControl = control.type === "color" || control.type === "colorOpacity";
1732
+
1733
+ if (isColorSectionTitle(sectionTitle)) {
1734
+ if (isColorControl && isSequentialColorLabel(label)) {
1735
+ return true;
1736
+ }
1737
+
1738
+ if (
1739
+ isSimplePaletteDistributionLabel(label) &&
1740
+ isGenericControlHelpDescription(description)
1741
+ ) {
1742
+ return true;
1743
+ }
1744
+ }
1745
+
1746
+ const normalizedDescription = normalizeControlHelpContext(description);
1747
+ const normalizedLabel = normalizeControlHelpContext(label);
1748
+
1749
+ return (
1750
+ Boolean(normalizedLabel) &&
1751
+ isGenericControlHelpDescription(description) &&
1752
+ normalizedDescription === normalizedLabel
1753
+ );
1754
+ }
1755
+
1756
+ function getControlHelpText({
1757
+ control,
1758
+ label,
1759
+ sectionTitle,
1760
+ }: {
1761
+ control: ToolcraftControlSchema;
1762
+ label: string;
1763
+ sectionTitle: string | undefined;
1764
+ }): string | null {
1492
1765
  const description = control.description?.trim();
1493
1766
 
1494
- return description || null;
1767
+ if (!description) {
1768
+ return null;
1769
+ }
1770
+
1771
+ if (
1772
+ shouldSuppressObviousControlHelp({
1773
+ control,
1774
+ description,
1775
+ label,
1776
+ sectionTitle,
1777
+ })
1778
+ ) {
1779
+ return null;
1780
+ }
1781
+
1782
+ return description;
1495
1783
  }
1496
1784
 
1497
1785
  function withControlLabelHelp({
@@ -1499,13 +1787,15 @@ export function ControlsPanel({
1499
1787
  control,
1500
1788
  label,
1501
1789
  providerKey,
1790
+ sectionTitle,
1502
1791
  }: {
1503
1792
  children: React.ReactNode;
1504
1793
  control: ToolcraftControlSchema;
1505
1794
  label: string;
1506
1795
  providerKey: string;
1796
+ sectionTitle: string | undefined;
1507
1797
  }): React.ReactNode {
1508
- const help = getControlHelpText(control);
1798
+ const help = getControlHelpText({ control, label, sectionTitle });
1509
1799
 
1510
1800
  if (!help) {
1511
1801
  return children;
@@ -1765,6 +2055,17 @@ export function ControlsPanel({
1765
2055
  );
1766
2056
  }
1767
2057
 
2058
+ case "aspectRatio":
2059
+ return (
2060
+ <CanvasAspectRatioControl
2061
+ defaultValue={control.defaultValue}
2062
+ key={id}
2063
+ name={name}
2064
+ onValueChange={commit}
2065
+ value={value}
2066
+ />
2067
+ );
2068
+
1768
2069
  case "anchorGrid":
1769
2070
  return withKeyframeLabelAction({
1770
2071
  children: (
@@ -1913,13 +2214,38 @@ export function ControlsPanel({
1913
2214
  }
1914
2215
 
1915
2216
  case "fileDrop": {
1916
- const previewMediaAsset = state.schema.panels.layers
1917
- ? undefined
1918
- : state.mediaAssets[0];
2217
+ const previewMediaAssets = state.schema.panels.layers ? [] : state.mediaAssets;
2218
+ const previewMediaAsset = previewMediaAssets[0];
2219
+ const previews = previewMediaAssets.map((asset): FileDropPreview => ({
2220
+ alt: asset.fileName,
2221
+ id: asset.id,
2222
+ size: asset.size,
2223
+ src: asset.dataUrl,
2224
+ }));
2225
+ const importFile = (file: File, replaceExisting: boolean): void => {
2226
+ void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
2227
+ if (!importedImage) {
2228
+ return;
2229
+ }
2230
+
2231
+ dispatchCommand({
2232
+ asset: {
2233
+ dataUrl: importedImage.dataUrl,
2234
+ fileName: file.name,
2235
+ mimeType: file.type || "image/*",
2236
+ position: { x: 0, y: 0 },
2237
+ size: importedImage.size,
2238
+ },
2239
+ replaceExisting,
2240
+ type: "media.import",
2241
+ });
2242
+ });
2243
+ };
1919
2244
 
1920
2245
  return (
1921
2246
  <FileDrop
1922
2247
  accept={control.accept ?? "PNG, JPEG, GIF, SVG, WebP"}
2248
+ multiple={control.multiple}
1923
2249
  key={id}
1924
2250
  onClear={
1925
2251
  previewMediaAsset
@@ -1931,33 +2257,31 @@ export function ControlsPanel({
1931
2257
  }
1932
2258
  : undefined
1933
2259
  }
1934
- onFileSelect={(file) => {
1935
- void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
1936
- if (!importedImage) {
1937
- return;
1938
- }
1939
-
1940
- dispatchCommand({
1941
- asset: {
1942
- dataUrl: importedImage.dataUrl,
1943
- fileName: file.name,
1944
- mimeType: file.type || "image/*",
1945
- position: { x: 0, y: 0 },
1946
- size: importedImage.size,
1947
- },
1948
- type: "media.import",
1949
- });
2260
+ onFilesSelect={(files) => {
2261
+ files.forEach((file) => importFile(file, false));
2262
+ }}
2263
+ onFileSelect={(file) => importFile(file, true)}
2264
+ onPreviewRemove={(item) => {
2265
+ if (!item.id) {
2266
+ return;
2267
+ }
2268
+
2269
+ dispatchCommand({
2270
+ mediaId: item.id,
2271
+ type: "media.delete",
1950
2272
  });
1951
2273
  }}
1952
2274
  preview={
1953
2275
  previewMediaAsset
1954
2276
  ? {
2277
+ id: previewMediaAsset.id,
1955
2278
  alt: previewMediaAsset.fileName,
1956
2279
  size: previewMediaAsset.size,
1957
2280
  src: previewMediaAsset.dataUrl,
1958
2281
  }
1959
2282
  : undefined
1960
2283
  }
2284
+ previews={previews}
1961
2285
  />
1962
2286
  );
1963
2287
  }
@@ -2298,6 +2622,7 @@ export function ControlsPanel({
2298
2622
  control,
2299
2623
  label: name,
2300
2624
  providerKey: id,
2625
+ sectionTitle: section.title,
2301
2626
  }),
2302
2627
  control,
2303
2628
  }),
@@ -130,6 +130,12 @@ describe("settings transfer", () => {
130
130
 
131
131
  expect(state.values["generation.prompt"]).toBe("Imported prompt");
132
132
  expect(state.values["unknown.target"]).toBeUndefined();
133
+ expect(state.values["canvas.aspectRatio"]).toEqual({
134
+ height: 9,
135
+ mode: "custom",
136
+ value: "16:9",
137
+ width: 16,
138
+ });
133
139
  expect(state.canvas.size).toEqual({ height: 900, unit: "px", width: 1600 });
134
140
  expect(state.timeline).toMatchObject({
135
141
  currentTimeSeconds: 3,
@@ -123,9 +123,33 @@ export function parseToolcraftSettingsPayload(
123
123
  }
124
124
 
125
125
  function applyCanvasSize(
126
- dispatch: ToolcraftDispatch,
126
+ { dispatch, state }: ImportContext,
127
127
  size: ToolcraftSettingsTransferPayload["canvas"]["size"],
128
+ options: { deriveAspectRatio: boolean },
128
129
  ): void {
130
+ const importableTargets = getKnownValueTargets(state.schema);
131
+
132
+ if (
133
+ options.deriveAspectRatio &&
134
+ importableTargets.has("canvas.aspectRatio") &&
135
+ isFinitePositiveNumber(size.width) &&
136
+ isFinitePositiveNumber(size.height)
137
+ ) {
138
+ dispatch({
139
+ history: "merge",
140
+ historyGroup: settingsTransferImportHistoryGroup,
141
+ label: "Import settings",
142
+ target: "canvas.aspectRatio",
143
+ type: "controls.setValue",
144
+ value: {
145
+ height: size.height,
146
+ mode: "custom",
147
+ value: `${size.width}:${size.height}`,
148
+ width: size.width,
149
+ },
150
+ });
151
+ }
152
+
129
153
  if (isFinitePositiveNumber(size.width)) {
130
154
  dispatch({
131
155
  history: "merge",
@@ -211,7 +235,9 @@ export function applyToolcraftSettingsPayload(
211
235
  });
212
236
  }
213
237
 
214
- applyCanvasSize(context.dispatch, payload.canvas.size);
238
+ applyCanvasSize(context, payload.canvas.size, {
239
+ deriveAspectRatio: !Object.hasOwn(payload.values, "canvas.aspectRatio"),
240
+ });
215
241
  applyTimeline(context, payload.timeline);
216
242
  }
217
243
 
@@ -114,6 +114,75 @@ describe("TimelinePanel", () => {
114
114
  expect(screen.getByTestId("timeline-looping").textContent).toBe("false");
115
115
  });
116
116
 
117
+ it("keeps timeline playhead handles easy to grab without changing visual size", () => {
118
+ const { container } = renderTimelinePanel();
119
+
120
+ const compactHandle = container.querySelector<HTMLElement>(
121
+ '[data-slot="timeline-playback-handle"]',
122
+ );
123
+
124
+ expect(compactHandle?.className).toContain("size-2");
125
+ expect(compactHandle?.className).toContain("before:inset-[-4px]");
126
+
127
+ fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
128
+
129
+ const expandedHitArea = container.querySelector<HTMLElement>(
130
+ '[data-slot="timeline-expanded-playhead-hit-area"]',
131
+ );
132
+ const expandedHandle = container.querySelector<HTMLElement>(
133
+ '[data-slot="timeline-expanded-playhead-handle"]',
134
+ );
135
+
136
+ expect(expandedHandle?.className).toContain("size-[9px]");
137
+ expect(expandedHitArea?.getAttribute("style")).toContain("width: 15px;");
138
+ });
139
+
140
+ it("edits timeline duration from the highlighted duration value in collapsed and expanded states", () => {
141
+ const { container } = renderTimelinePanel();
142
+
143
+ const durationDisplay = container.querySelector<HTMLElement>(
144
+ '[data-slot="timeline-duration-display"]',
145
+ );
146
+
147
+ expect(durationDisplay?.className).toContain("!cursor-text");
148
+ expect(durationDisplay?.className).toContain("group-hover/timeline-panel-header:bg");
149
+ expect(durationDisplay?.className).toContain("var(--foreground)_8%");
150
+ expect(durationDisplay?.className).toContain("px-1");
151
+ expect(container.querySelector('[data-slot="timeline-duration-edit-trigger"]')).toBeNull();
152
+ expect(screen.queryByRole("textbox", { name: "timeline duration" })).toBeNull();
153
+
154
+ fireEvent.click(screen.getByRole("button", { name: "Edit timeline duration" }));
155
+
156
+ const editor = screen.getByRole("textbox", { name: "timeline duration" });
157
+
158
+ expect(editor.className).toContain("var(--foreground)_8%");
159
+ expect(editor.className).toContain("px-1");
160
+ expect(editor.className).toContain("!cursor-text");
161
+ expect(screen.queryByRole("button", { name: "Edit timeline duration" })).toBeNull();
162
+
163
+ editor.textContent = "12s";
164
+ fireEvent.keyDown(editor, { key: "Enter" });
165
+
166
+ expect(screen.getByText("12s")).toBeTruthy();
167
+
168
+ fireEvent.click(screen.getByRole("button", { name: "Expand timeline panel" }));
169
+
170
+ const expandedDurationDisplay = container.querySelector<HTMLElement>(
171
+ '[data-slot="timeline-duration-display"]',
172
+ );
173
+
174
+ expect(expandedDurationDisplay?.className).toContain("!cursor-text");
175
+ expect(expandedDurationDisplay?.className).toContain("group-hover/timeline-panel-header:bg");
176
+ expect(expandedDurationDisplay?.className).toContain("var(--foreground)_8%");
177
+
178
+ fireEvent.click(screen.getByRole("button", { name: "Edit timeline duration" }));
179
+
180
+ const expandedEditor = screen.getByRole("textbox", { name: "timeline duration" });
181
+
182
+ expect(expandedEditor.className).toContain("var(--foreground)_8%");
183
+ expect(expandedEditor.className).toContain("!cursor-text");
184
+ });
185
+
117
186
  it("replays from the beginning when play is pressed at the non-looping end", () => {
118
187
  renderTimelinePanel(
119
188
  {},