@pixel-point/toolcraft 0.0.9 → 0.0.12

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 (77) hide show
  1. package/README.md +42 -9
  2. package/package.json +2 -1
  3. package/src/cli.mjs +4 -2
  4. package/src/cli.test.mjs +5 -2
  5. package/src/generate.mjs +14 -6
  6. package/src/generate.test.mjs +33 -2
  7. package/templates/runtime/contracts/component-contracts.test.ts +175 -36
  8. package/templates/runtime/contracts/component-contracts.ts +90 -45
  9. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +478 -27
  17. package/templates/runtime/react/controls-panel.tsx +71 -26
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  27. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +41 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +657 -2
  41. package/templates/runtime/testing/performance.ts +789 -49
  42. package/templates/starter/AGENTS.md +22 -16
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +41 -8
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +58 -37
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +30 -10
  50. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +57 -4
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/index.html +1 -0
  56. package/templates/starter/package.json +3 -2
  57. package/templates/starter/playwright.config.ts +1 -1
  58. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  59. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  60. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  61. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  62. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  63. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  64. package/templates/starter/src/app/starter-performance.test.ts +130 -8
  65. package/templates/starter/src/app/starter-performance.ts +5 -0
  66. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  67. package/templates/starter/src/app/starter-schema.ts +6 -2
  68. package/templates/starter/vite.config.ts +58 -2
  69. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  70. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  71. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  72. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  73. package/templates/ui/components/controls/index.ts +2 -0
  74. package/templates/ui/components/controls/range-slider/range-slider-value.ts +19 -5
  75. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  76. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  77. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -65,7 +65,12 @@ import {
65
65
  toolcraftCanvasAspectRatioPresets,
66
66
  } from "../schema/canvas-aspect-ratio-presets";
67
67
  import { getToolcraftControlKeyframeCapability } from "../schema/keyframe-capability";
68
- import { getToolcraftCanvasSizeTargetDimension } from "../schema/runtime-targets";
68
+ import {
69
+ getToolcraftCanvasSizeTargetDimension,
70
+ isToolcraftRuntimeOwnedTarget,
71
+ isToolcraftTimelinePanelExtendedTarget,
72
+ isToolcraftTimelinePanelVisibleTarget,
73
+ } from "../schema/runtime-targets";
69
74
  import type {
70
75
  ToolcraftCommand,
71
76
  ToolcraftPanelState,
@@ -785,7 +790,6 @@ function CanvasAspectRatioControl({
785
790
  return (
786
791
  <div className="min-w-0 space-y-2" data-slot="canvas-aspect-ratio-control">
787
792
  <Select
788
- layout="stacked"
789
793
  name={name}
790
794
  onValueChange={updatePreset}
791
795
  options={canvasAspectRatioOptions}
@@ -850,6 +854,23 @@ function getActionLabel(action: ToolcraftActionSchema): string {
850
854
  return action.label ?? action.value;
851
855
  }
852
856
 
857
+ function isExportPanelAction(action: ToolcraftActionSchema): boolean {
858
+ const label = getActionLabel(action);
859
+ const value = action.value;
860
+
861
+ return (
862
+ /\bexport\b/i.test(label) ||
863
+ /(?:^|[._:-])export(?:[._:-]|$)/i.test(value) ||
864
+ /^export(?:[._:-]|$)/i.test(value)
865
+ );
866
+ }
867
+
868
+ function getPanelActionIcon(
869
+ action: ToolcraftActionSchema,
870
+ ): PanelActionObjectOption["icon"] {
871
+ return isExportPanelAction(action) ? "upload-simple" : action.icon;
872
+ }
873
+
853
874
  function getControlName(id: string, label: boolean | string | undefined): string {
854
875
  if (typeof label === "string") {
855
876
  return label;
@@ -986,26 +1007,19 @@ function isColorOnlySection(entries: readonly ControlEntry[]): boolean {
986
1007
  function getRenderedControlsSectionTitle(
987
1008
  section: ToolcraftControlSectionSchema,
988
1009
  ): ToolcraftControlSectionSchema["title"] {
989
- return shouldHideRuntimeControlsSectionTitle(section) ? undefined : section.title;
990
- }
991
-
992
- function shouldHideRuntimeControlsSectionTitle(
993
- section: ToolcraftControlSectionSchema,
994
- ): boolean {
995
- return isRuntimeSetupSection(section) || isStickyFooterActionSection(section);
1010
+ return isStickyFooterActionSection(section) ? undefined : section.title;
996
1011
  }
997
1012
 
998
- function isRuntimeSetupSection(section: ToolcraftControlSectionSchema): boolean {
999
- if (section.title !== "Setup") {
1000
- return false;
1001
- }
1002
-
1003
- return Object.values(section.controls).some(
1004
- (control) =>
1005
- control.type === "settingsTransfer" ||
1006
- control.target === "canvas.aspectRatio" ||
1007
- control.target === "canvas.size.width" ||
1008
- control.target === "canvas.size.height",
1013
+ function isRuntimeSetupSection({
1014
+ entries,
1015
+ section,
1016
+ }: {
1017
+ entries: readonly ControlEntry[];
1018
+ section: ToolcraftControlSectionSchema;
1019
+ }): boolean {
1020
+ return (
1021
+ section.title === "Setup" &&
1022
+ entries.some(([, control]) => isToolcraftRuntimeOwnedTarget(control.target))
1009
1023
  );
1010
1024
  }
1011
1025
 
@@ -1614,6 +1628,14 @@ export function ControlsPanel({
1614
1628
  function getControlValue(control: ToolcraftControlSchema): unknown {
1615
1629
  const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(control.target);
1616
1630
 
1631
+ if (isToolcraftTimelinePanelExtendedTarget(control.target)) {
1632
+ return state.panels.timeline.extended === true;
1633
+ }
1634
+
1635
+ if (isToolcraftTimelinePanelVisibleTarget(control.target)) {
1636
+ return state.panels.timeline.hidden !== true;
1637
+ }
1638
+
1617
1639
  return canvasSizeDimension
1618
1640
  ? state.canvas.size[canvasSizeDimension]
1619
1641
  : (state.values[control.target] ?? control.defaultValue);
@@ -1634,6 +1656,14 @@ export function ControlsPanel({
1634
1656
  function getTargetValue(target: string): unknown {
1635
1657
  const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(target);
1636
1658
 
1659
+ if (isToolcraftTimelinePanelExtendedTarget(target)) {
1660
+ return state.panels.timeline.extended === true;
1661
+ }
1662
+
1663
+ if (isToolcraftTimelinePanelVisibleTarget(target)) {
1664
+ return state.panels.timeline.hidden !== true;
1665
+ }
1666
+
1637
1667
  return canvasSizeDimension
1638
1668
  ? state.canvas.size[canvasSizeDimension]
1639
1669
  : (state.values[target] ?? getControlDefaultValueByTarget(target));
@@ -2371,14 +2401,17 @@ export function ControlsPanel({
2371
2401
  {visibleSections.map(({ entries, section }, sectionIndex) => {
2372
2402
  const visibleControls = getControlsRecord(entries);
2373
2403
  const sectionHasOnlyColorFields = isColorOnlySection(entries);
2374
- const renderedSectionTitle = getRenderedControlsSectionTitle(section);
2375
- const sectionSpacing = isRuntimeSetupSection(section) ? "technical" : "default";
2404
+ const isRuntimeSetup = isRuntimeSetupSection({ entries, section });
2405
+ const renderedSectionTitle = isRuntimeSetup
2406
+ ? undefined
2407
+ : getRenderedControlsSectionTitle(section);
2408
+ const sectionSpacing = "default";
2376
2409
  const sectionCollapseKey = getControlsPanelSectionCollapseKey({
2377
2410
  entries,
2378
2411
  section,
2379
2412
  sectionIndex,
2380
2413
  });
2381
- const isSectionCollapsible = renderedSectionTitle !== undefined;
2414
+ const isSectionCollapsible = !isRuntimeSetup && renderedSectionTitle !== undefined;
2382
2415
  const isSectionCollapsed =
2383
2416
  isSectionCollapsible && collapsedSectionByKey[sectionCollapseKey] === true;
2384
2417
  const headerKeyframeEntry = getSectionHeaderKeyframeEntry(entries, section.title);
@@ -2677,6 +2710,7 @@ export function ControlsPanel({
2677
2710
  id: asset.id,
2678
2711
  size: asset.size,
2679
2712
  src: asset.dataUrl,
2713
+ transform: asset.transform,
2680
2714
  }));
2681
2715
  const previewMediaIds = previewMediaAssets.map((asset) => asset.id);
2682
2716
  const importFile = (file: File, replaceExisting: boolean): void => {
@@ -2786,6 +2820,17 @@ export function ControlsPanel({
2786
2820
  type: "media.reorder",
2787
2821
  });
2788
2822
  }}
2823
+ onPreviewTransform={(item, operation) => {
2824
+ if (!item.id) {
2825
+ return;
2826
+ }
2827
+
2828
+ dispatchCommand({
2829
+ mediaId: item.id,
2830
+ operation,
2831
+ type: "media.transform",
2832
+ });
2833
+ }}
2789
2834
  preview={
2790
2835
  previewMediaAsset
2791
2836
  ? {
@@ -2793,6 +2838,7 @@ export function ControlsPanel({
2793
2838
  alt: previewMediaAsset.fileName,
2794
2839
  size: previewMediaAsset.size,
2795
2840
  src: previewMediaAsset.dataUrl,
2841
+ transform: previewMediaAsset.transform,
2796
2842
  }
2797
2843
  : undefined
2798
2844
  }
@@ -2867,7 +2913,7 @@ export function ControlsPanel({
2867
2913
  : undefined
2868
2914
  }
2869
2915
  key={id}
2870
- onCommit={(nextValue) => commit(nextValue)}
2916
+ onValueChange={commit}
2871
2917
  value={
2872
2918
  isRecord(value)
2873
2919
  ? (value as React.ComponentProps<typeof Palette>["value"])
@@ -2888,7 +2934,7 @@ export function ControlsPanel({
2888
2934
  return (
2889
2935
  <PanelActions
2890
2936
  actions={actions.map((action) => ({
2891
- icon: action.icon,
2937
+ icon: getPanelActionIcon(action),
2892
2938
  name: getActionLabel(action),
2893
2939
  value: action.value,
2894
2940
  variant: getPanelActionButtonVariant(action.variant),
@@ -2979,7 +3025,6 @@ export function ControlsPanel({
2979
3025
  return (
2980
3026
  <Select
2981
3027
  key={id}
2982
- layout={isInInlineLayoutGroup ? "stacked" : undefined}
2983
3028
  name={name}
2984
3029
  onValueChange={commit}
2985
3030
  options={control.options ?? []}
@@ -48,7 +48,7 @@ describe("Toolcraft template runtime public API", () => {
48
48
  >().toEqualTypeOf<ControlsPanelKeys>();
49
49
  expectTypeOf<
50
50
  keyof React.ComponentProps<typeof TimelinePanel>
51
- >().toEqualTypeOf<RuntimePanelKeys | "defaultExpanded">();
51
+ >().toEqualTypeOf<RuntimePanelKeys | "defaultExpanded" | "variant">();
52
52
  expectTypeOf<
53
53
  keyof React.ComponentProps<typeof ToolbarPanel>
54
54
  >().toEqualTypeOf<RuntimePanelKeys>();
@@ -64,6 +64,8 @@ describe("settings transfer", () => {
64
64
  const state = createToolcraftState(schema, {
65
65
  values: {
66
66
  "generation.prompt": "Exported prompt",
67
+ "panels.timeline.extended": true,
68
+ "panels.timeline.visible": false,
67
69
  "style.opacity": 24,
68
70
  },
69
71
  });
@@ -82,6 +84,8 @@ describe("settings transfer", () => {
82
84
  },
83
85
  version: 1,
84
86
  });
87
+ expect(payload.values).not.toHaveProperty("panels.timeline.extended");
88
+ expect(payload.values).not.toHaveProperty("panels.timeline.visible");
85
89
  expect(parseToolcraftSettingsPayload(schema, payload)).toEqual(payload);
86
90
  expect(
87
91
  parseToolcraftSettingsPayload(
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedToolcraftAppSchema } from "../schema/types";
2
+ import { isToolcraftTimelinePanelRuntimeTarget } from "../schema/runtime-targets";
2
3
  import type {
3
4
  ToolcraftCommand,
4
5
  ToolcraftState,
@@ -50,7 +51,11 @@ function getKnownValueTargets(schema: ResolvedToolcraftAppSchema): Set<string> {
50
51
 
51
52
  for (const section of schema.panels.controls?.sections ?? []) {
52
53
  for (const control of Object.values(section.controls)) {
53
- if (control.type !== "panelActions" && control.type !== "settingsTransfer") {
54
+ if (
55
+ control.type !== "panelActions" &&
56
+ control.type !== "settingsTransfer" &&
57
+ !isToolcraftTimelinePanelRuntimeTarget(control.target)
58
+ ) {
54
59
  targets.add(control.target);
55
60
  }
56
61
  }
@@ -97,6 +97,20 @@ describe("TimelinePanel", () => {
97
97
  expect(screen.queryByText("Add your first keyframe from the properties panel.")).toBeNull();
98
98
  });
99
99
 
100
+ it("renders compact presentation as playback-only transport", () => {
101
+ renderTimelinePanel({ variant: "compact" });
102
+
103
+ expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
104
+ expect(screen.queryByRole("button", { name: "Disable loop" })).toBeNull();
105
+ expect(screen.queryByText("Dur:")).toBeNull();
106
+ expect(screen.queryByText("Duration:")).toBeNull();
107
+ expect(screen.queryByRole("slider", { name: "Playback position" })).toBeNull();
108
+
109
+ fireEvent.click(screen.getByRole("button", { name: "Pause playback" }));
110
+
111
+ expect(screen.getByTestId("timeline-playing").textContent).toBe("false");
112
+ });
113
+
100
114
  it("renders timeline state and dispatches playback commands", () => {
101
115
  renderTimelinePanel();
102
116
 
@@ -31,6 +31,7 @@ import type {
31
31
  ToolcraftTimelineKeyframeEasing,
32
32
  ToolcraftTimelineKeyframeGroup,
33
33
  } from '../state/types';
34
+ import { getToolcraftTimelineLoopTime } from '../state/timeline-loop';
34
35
  import { isTimelineReadyForPlayback } from '../state/timeline-readiness';
35
36
  import { PanelContainer } from './panel-host';
36
37
  import type { PanelPlacement, PanelStateChange } from './panel-host-types';
@@ -43,6 +44,7 @@ type TimelinePanelProps = {
43
44
  onPanelStateChange?: PanelStateChange;
44
45
  panelPlacement?: PanelPlacement;
45
46
  panelState?: ToolcraftPanelState;
47
+ variant?: 'compact' | 'extended';
46
48
  };
47
49
 
48
50
  function cn(...classNames: Array<string | false | null | undefined>): string {
@@ -50,6 +52,7 @@ function cn(...classNames: Array<string | false | null | undefined>): string {
50
52
  }
51
53
 
52
54
  const timelinePanelCollapsedSize = { height: 36 } as const;
55
+ const timelinePanelCompactWidthPx = 36;
53
56
  const timelinePanelCollapsedWidthPx = 256;
54
57
  const timelinePanelExpandedWidthPx = 688;
55
58
  const timelinePanelMinResponsiveWidthPx = 320;
@@ -2336,12 +2339,15 @@ function useTimelineClock({
2336
2339
  setCurrentTimeSeconds((currentValue) => {
2337
2340
  const nextValue = currentValue + elapsedSeconds;
2338
2341
 
2339
- if (nextValue <= durationSeconds) {
2342
+ if (nextValue < durationSeconds) {
2340
2343
  return nextValue;
2341
2344
  }
2342
2345
 
2343
2346
  if (isLooping) {
2344
- return nextValue % durationSeconds;
2347
+ return getToolcraftTimelineLoopTime({
2348
+ currentTimeSeconds: nextValue,
2349
+ durationSeconds,
2350
+ });
2345
2351
  }
2346
2352
 
2347
2353
  setIsPlaying(false);
@@ -2608,6 +2614,7 @@ function TimelinePanelHeader({
2608
2614
  onToggleLoop,
2609
2615
  onTogglePlayback,
2610
2616
  stripRef,
2617
+ variant,
2611
2618
  }: {
2612
2619
  canExpand: boolean;
2613
2620
  currentTimeSeconds: number;
@@ -2626,7 +2633,31 @@ function TimelinePanelHeader({
2626
2633
  onToggleLoop: () => void;
2627
2634
  onTogglePlayback: () => void;
2628
2635
  stripRef: React.RefObject<HTMLDivElement | null>;
2636
+ variant: 'compact' | 'extended';
2629
2637
  }): React.JSX.Element {
2638
+ if (variant === 'compact') {
2639
+ return (
2640
+ <div
2641
+ className="relative flex h-full min-w-0 shrink-0 items-center justify-center"
2642
+ data-slot="timeline-panel-header"
2643
+ data-timeline-panel-variant="compact"
2644
+ >
2645
+ <div
2646
+ className="relative z-10 inline-flex shrink-0 items-center"
2647
+ data-slot="timeline-transport-controls"
2648
+ >
2649
+ <TimelineIconButton
2650
+ disabled={!playbackReady}
2651
+ label={isPlaying ? 'Pause playback' : 'Play playback'}
2652
+ onClick={onTogglePlayback}
2653
+ >
2654
+ {isPlaying ? <Pause /> : <Play />}
2655
+ </TimelineIconButton>
2656
+ </div>
2657
+ </div>
2658
+ );
2659
+ }
2660
+
2630
2661
  return (
2631
2662
  <div
2632
2663
  className={cn(
@@ -2705,6 +2736,7 @@ export function TimelinePanel({
2705
2736
  onPanelStateChange,
2706
2737
  panelPlacement,
2707
2738
  panelState,
2739
+ variant = 'extended',
2708
2740
  }: TimelinePanelProps): React.JSX.Element | null {
2709
2741
  const { dispatch, state } = useToolcraft();
2710
2742
 
@@ -2727,7 +2759,8 @@ export function TimelinePanel({
2727
2759
  const [defaultExpandedPending, setDefaultExpandedPending] = useState(defaultExpanded);
2728
2760
  const [isHoverPaused, setIsHoverPaused] = useState(false);
2729
2761
  const displayedIsPlaying = playbackReady && isPlaying;
2730
- const isExpanded = keyframesEnabled && (expanded || defaultExpandedPending);
2762
+ const isCompact = variant === 'compact';
2763
+ const isExpanded = !isCompact && keyframesEnabled && (expanded || defaultExpandedPending);
2731
2764
  const expandedPanelSize = getTimelinePanelExpandedSize(keyframeGroups);
2732
2765
  const previousIsExpandedRef = useRef(isExpanded);
2733
2766
  const timelineRef = useRef(state.timeline);
@@ -2911,7 +2944,9 @@ export function TimelinePanel({
2911
2944
  const { panelRef: timelineSurfaceRef, responsiveLayout } = useTimelinePanelResponsiveLayout(
2912
2945
  isExpanded && !shouldConstrainToContainer,
2913
2946
  );
2914
- const unconstrainedTimelinePanelWidth = isExpanded
2947
+ const unconstrainedTimelinePanelWidth = isCompact
2948
+ ? timelinePanelCompactWidthPx
2949
+ : isExpanded
2915
2950
  ? expandedPanelSize.width
2916
2951
  : timelinePanelCollapsedWidthPx;
2917
2952
  const timelinePanelWidth =
@@ -2947,6 +2982,7 @@ export function TimelinePanel({
2947
2982
  data-playback-ready={playbackReady ? 'true' : 'false'}
2948
2983
  data-scrubbing={scrubber.isScrubbing ? 'true' : 'false'}
2949
2984
  data-slot="timeline-panel"
2985
+ data-timeline-panel-variant={variant}
2950
2986
  initial={false}
2951
2987
  ref={timelineSurfaceRef}
2952
2988
  style={timelinePanelLayoutStyle}
@@ -2956,7 +2992,7 @@ export function TimelinePanel({
2956
2992
  className={cn(
2957
2993
  'group/timeline-panel-surface relative flex h-full w-full flex-col rounded-t-lg rounded-b-lg',
2958
2994
  isExpanded ? 'overflow-hidden' : 'overflow-visible p-1',
2959
- !isExpanded && !keyframesEnabled && 'pr-3',
2995
+ !isCompact && !isExpanded && !keyframesEnabled && 'pr-3',
2960
2996
  )}
2961
2997
  data-panel-id="timeline"
2962
2998
  onPointerEnter={() => setIsHoverPaused(true)}
@@ -2970,13 +3006,13 @@ export function TimelinePanel({
2970
3006
  setIsHoverPaused(false);
2971
3007
  }}
2972
3008
  >
2973
- {isExpanded ? null : (
3009
+ {!isCompact && !isExpanded ? (
2974
3010
  <TimelinePanelMask
2975
3011
  currentTimeSeconds={currentTimeSeconds}
2976
3012
  durationSeconds={durationSeconds}
2977
3013
  isHandleVisible={isHoverPaused || scrubber.isScrubbing}
2978
3014
  />
2979
- )}
3015
+ ) : null}
2980
3016
  <TimelinePanelHeader
2981
3017
  canExpand={keyframesEnabled}
2982
3018
  currentTimeSeconds={currentTimeSeconds}
@@ -3001,6 +3037,7 @@ export function TimelinePanel({
3001
3037
  dispatch({ type: 'timeline.togglePlayback' });
3002
3038
  }}
3003
3039
  stripRef={scrubber.stripRef}
3040
+ variant={variant}
3004
3041
  />
3005
3042
  {isExpanded && keyframesEnabled ? (
3006
3043
  <TimelineExpandedContent
@@ -188,6 +188,10 @@ describe("assembled Toolcraft template runtime", () => {
188
188
  renderAssembledEditor();
189
189
 
190
190
  expect(screen.getByText("Layers")).toBeTruthy();
191
+ const timelineField = screen.getByText("Timeline").closest('[role="group"]');
192
+ const timelineSwitch = timelineField?.querySelector<HTMLElement>('[role="switch"]');
193
+
194
+ fireEvent.click(timelineSwitch as HTMLElement);
191
195
  fireEvent.click(
192
196
  screen.getByRole("button", { name: "Expand timeline panel" }),
193
197
  );
@@ -266,7 +270,8 @@ describe("assembled Toolcraft template runtime", () => {
266
270
  ).toBe("320px");
267
271
  expect(screen.getByText("Master Controls")).toBeTruthy();
268
272
  expect(screen.getByText("Layers")).toBeTruthy();
269
- expect(screen.getByText("Dur:")).toBeTruthy();
273
+ expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
274
+ expect(screen.queryByText("Dur:")).toBeNull();
270
275
  expect(screen.queryByText("Duration:")).toBeNull();
271
276
  expect(toolbar?.textContent).toContain("100%");
272
277
  expect(screen.getByRole("button", { name: "Undo" })).toBeDisabled();
@@ -339,6 +344,9 @@ describe("assembled Toolcraft template runtime", () => {
339
344
  });
340
345
  expect(screen.getByRole("button", { name: "Undo" })).not.toBeDisabled();
341
346
 
347
+ const timelineField = screen.getByText("Timeline").closest('[role="group"]');
348
+ const timelineSwitch = timelineField?.querySelector<HTMLElement>('[role="switch"]');
349
+ fireEvent.click(timelineSwitch as HTMLElement);
342
350
  fireEvent.click(
343
351
  screen.getByRole("button", { name: "Expand timeline panel" }),
344
352
  );
@@ -1,4 +1,5 @@
1
1
  import {
2
+ act,
2
3
  cleanup,
3
4
  fireEvent,
4
5
  render,
@@ -6,7 +7,7 @@ import {
6
7
  waitFor,
7
8
  } from "@testing-library/react";
8
9
  import * as React from "react";
9
- import { afterEach, beforeAll, describe, expect, it } from "vitest";
10
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
10
11
 
11
12
  import { defineToolcraft } from "../schema/define-toolcraft";
12
13
  import type { ToolcraftCustomControlRendererProps } from "./control-renderers";
@@ -27,9 +28,17 @@ beforeAll(() => {
27
28
  }),
28
29
  writable: true,
29
30
  });
31
+ window.requestAnimationFrame ??= ((callback: FrameRequestCallback) =>
32
+ window.setTimeout(
33
+ () => callback(performance.now()),
34
+ 16,
35
+ )) as typeof window.requestAnimationFrame;
36
+ window.cancelAnimationFrame ??= ((handle: number) =>
37
+ window.clearTimeout(handle)) as typeof window.cancelAnimationFrame;
30
38
  });
31
39
 
32
40
  afterEach(() => {
41
+ vi.useRealTimers();
33
42
  cleanup();
34
43
  document.documentElement.classList.remove("dark");
35
44
  document.documentElement.style.colorScheme = "";
@@ -113,6 +122,16 @@ function CustomValueProbe(): React.JSX.Element {
113
122
  );
114
123
  }
115
124
 
125
+ function TimelineTimeProbe(): React.JSX.Element {
126
+ const { state } = useToolcraft();
127
+
128
+ return (
129
+ <span data-testid="timeline-time">
130
+ {state.timeline.currentTimeSeconds.toFixed(3)}
131
+ </span>
132
+ );
133
+ }
134
+
116
135
  describe("ToolcraftApp", () => {
117
136
  it("renders enabled runtime surfaces from the app schema", () => {
118
137
  const { container } = render(<ToolcraftApp schema={createSchema()} />);
@@ -122,7 +141,11 @@ describe("ToolcraftApp", () => {
122
141
  ).toBeTruthy();
123
142
  expect(screen.getByText("Controls")).toBeTruthy();
124
143
  expect(screen.getByText("Layers")).toBeTruthy();
125
- expect(screen.getByText("Dur:")).toBeTruthy();
144
+ expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
145
+ expect(
146
+ container.querySelector('[data-toolcraft-timeline-panel-variant="compact"]'),
147
+ ).toBeTruthy();
148
+ expect(screen.queryByText("Dur:")).toBeNull();
126
149
  expect(screen.queryByText("Duration:")).toBeNull();
127
150
  expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
128
151
  expect(screen.getByRole("button", { name: "Redo" })).toBeTruthy();
@@ -145,6 +168,92 @@ describe("ToolcraftApp", () => {
145
168
  expect(container.querySelector('[data-panel-type="toolbar"]')).toBeTruthy();
146
169
  });
147
170
 
171
+ it("switches the timeline panel between compact and extended through the runtime setup toggle", () => {
172
+ const { container } = render(<ToolcraftApp schema={createSchema()} />);
173
+
174
+ expect(container.querySelector('[data-panel-type="timeline"]')).toBeTruthy();
175
+ expect(
176
+ container.querySelector('[data-toolcraft-timeline-panel-variant="compact"]'),
177
+ ).toBeTruthy();
178
+ expect(screen.getByRole("button", { name: "Pause playback" })).toBeTruthy();
179
+ expect(screen.queryByText("Dur:")).toBeNull();
180
+
181
+ const timelineField = screen.getByText("Timeline").closest('[role="group"]');
182
+ const timelineSwitch = timelineField?.querySelector<HTMLElement>('[role="switch"]');
183
+
184
+ expect(timelineSwitch?.getAttribute("aria-checked")).toBe("false");
185
+
186
+ fireEvent.click(timelineSwitch as HTMLElement);
187
+
188
+ expect(container.querySelector('[data-panel-type="timeline"]')).toBeTruthy();
189
+ expect(
190
+ container.querySelector('[data-toolcraft-timeline-panel-variant="extended"]'),
191
+ ).toBeTruthy();
192
+ expect(container.querySelector('[data-toolcraft-timeline-panel-hidden="true"]')).toBeNull();
193
+ expect(screen.getByText("Dur:")).toBeTruthy();
194
+ expect(screen.getByText(/\d+(?:\.\d+)? \/ 8s/)).toBeTruthy();
195
+ expect(timelineSwitch?.getAttribute("aria-checked")).toBe("true");
196
+ });
197
+
198
+ it("keeps timeline playback running while the panel is hidden", () => {
199
+ vi.useFakeTimers();
200
+ const schema = defineToolcraft({
201
+ canvas: {
202
+ enabled: true,
203
+ size: { height: 180, unit: "px", width: 320 },
204
+ },
205
+ panels: {
206
+ controls: {
207
+ sections: [],
208
+ title: "Controls",
209
+ },
210
+ timeline: { mode: "playback" },
211
+ },
212
+ persistence: {
213
+ include: ["panels", "timeline"],
214
+ key: "toolcraft:timeline-hidden-test:state:v1",
215
+ storage: "localStorage",
216
+ version: 1,
217
+ },
218
+ });
219
+
220
+ window.localStorage.setItem(
221
+ "toolcraft:timeline-hidden-test:state:v1",
222
+ JSON.stringify({
223
+ state: {
224
+ panels: {
225
+ timeline: { hidden: true },
226
+ },
227
+ timeline: {
228
+ currentTimeSeconds: 0,
229
+ durationSeconds: 8,
230
+ expanded: false,
231
+ isLooping: true,
232
+ isPlaying: true,
233
+ },
234
+ },
235
+ version: 1,
236
+ }),
237
+ );
238
+
239
+ const { container } = render(
240
+ <ToolcraftApp canvasContent={<TimelineTimeProbe />} schema={schema} />,
241
+ );
242
+
243
+ expect(
244
+ container.querySelector('[data-toolcraft-timeline-panel-hidden="true"]'),
245
+ ).toBeTruthy();
246
+ expect(screen.getByTestId("timeline-time").textContent).toBe("0.000");
247
+
248
+ act(() => {
249
+ vi.advanceTimersByTime(200);
250
+ });
251
+
252
+ expect(Number(screen.getByTestId("timeline-time").textContent)).toBeGreaterThan(0);
253
+
254
+ vi.useRealTimers();
255
+ });
256
+
148
257
  it("does not render disabled optional panels or toolbar features", () => {
149
258
  const schema = defineToolcraft({
150
259
  canvas: { enabled: true },
@@ -272,7 +381,7 @@ describe("ToolcraftApp", () => {
272
381
  footer: {
273
382
  actions: [
274
383
  {
275
- icon: "export",
384
+ icon: "upload-simple",
276
385
  label: "Export image",
277
386
  value: "export",
278
387
  variant: "default",