@pixel-point/toolcraft 0.0.8 → 0.0.11

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 (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  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 +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  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 +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -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 +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  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 +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -47,6 +47,7 @@ import {
47
47
  type GradientStop,
48
48
  type GradientType,
49
49
  type ImagePickerItem,
50
+ type VectorPadCoordinateMode,
50
51
  type VectorPadVariant,
51
52
  } from "@repo/ui";
52
53
 
@@ -64,7 +65,12 @@ import {
64
65
  toolcraftCanvasAspectRatioPresets,
65
66
  } from "../schema/canvas-aspect-ratio-presets";
66
67
  import { getToolcraftControlKeyframeCapability } from "../schema/keyframe-capability";
67
- import { getToolcraftCanvasSizeTargetDimension } from "../schema/runtime-targets";
68
+ import {
69
+ getToolcraftCanvasSizeTargetDimension,
70
+ isToolcraftRuntimeOwnedTarget,
71
+ isToolcraftTimelinePanelExtendedTarget,
72
+ isToolcraftTimelinePanelVisibleTarget,
73
+ } from "../schema/runtime-targets";
68
74
  import type {
69
75
  ToolcraftCommand,
70
76
  ToolcraftPanelState,
@@ -421,6 +427,17 @@ function asVectorPadVariant(value: string | undefined): VectorPadVariant {
421
427
  return "default";
422
428
  }
423
429
 
430
+ function asVectorPadCoordinateMode(
431
+ value: string | undefined,
432
+ padVariant: VectorPadVariant,
433
+ ): VectorPadCoordinateMode {
434
+ if (value === "cartesian" || value === "screen") {
435
+ return value;
436
+ }
437
+
438
+ return padVariant === "default" ? "screen" : "cartesian";
439
+ }
440
+
424
441
  function asCurveInterpolation(value: string | undefined): CurveInterpolation | undefined {
425
442
  return value === "monotone" || value === "smooth" ? value : undefined;
426
443
  }
@@ -773,7 +790,6 @@ function CanvasAspectRatioControl({
773
790
  return (
774
791
  <div className="min-w-0 space-y-2" data-slot="canvas-aspect-ratio-control">
775
792
  <Select
776
- layout="stacked"
777
793
  name={name}
778
794
  onValueChange={updatePreset}
779
795
  options={canvasAspectRatioOptions}
@@ -838,6 +854,23 @@ function getActionLabel(action: ToolcraftActionSchema): string {
838
854
  return action.label ?? action.value;
839
855
  }
840
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
+
841
874
  function getControlName(id: string, label: boolean | string | undefined): string {
842
875
  if (typeof label === "string") {
843
876
  return label;
@@ -974,26 +1007,19 @@ function isColorOnlySection(entries: readonly ControlEntry[]): boolean {
974
1007
  function getRenderedControlsSectionTitle(
975
1008
  section: ToolcraftControlSectionSchema,
976
1009
  ): ToolcraftControlSectionSchema["title"] {
977
- return shouldHideRuntimeControlsSectionTitle(section) ? undefined : section.title;
1010
+ return isStickyFooterActionSection(section) ? undefined : section.title;
978
1011
  }
979
1012
 
980
- function shouldHideRuntimeControlsSectionTitle(
981
- section: ToolcraftControlSectionSchema,
982
- ): boolean {
983
- return isRuntimeSetupSection(section) || isStickyFooterActionSection(section);
984
- }
985
-
986
- function isRuntimeSetupSection(section: ToolcraftControlSectionSchema): boolean {
987
- if (section.title !== "Setup") {
988
- return false;
989
- }
990
-
991
- return Object.values(section.controls).some(
992
- (control) =>
993
- control.type === "settingsTransfer" ||
994
- control.target === "canvas.aspectRatio" ||
995
- control.target === "canvas.size.width" ||
996
- 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))
997
1023
  );
998
1024
  }
999
1025
 
@@ -1154,6 +1180,15 @@ function hasSliderControl(
1154
1180
  );
1155
1181
  }
1156
1182
 
1183
+ function hasSegmentedControl(
1184
+ controlsById: Record<string, ToolcraftControlSchema>,
1185
+ layoutGroup: ToolcraftControlLayoutGroupSchema,
1186
+ ): boolean {
1187
+ return layoutGroup.controls.some(
1188
+ (controlId) => controlsById[controlId]?.type === "segmented",
1189
+ );
1190
+ }
1191
+
1157
1192
  function hasSectionedCompoundControl(
1158
1193
  controlsById: Record<string, ToolcraftControlSchema>,
1159
1194
  layoutGroup: ToolcraftControlLayoutGroupSchema,
@@ -1214,6 +1249,22 @@ function isToggleParameterLayoutGroup(
1214
1249
  return booleanControlCount === 1 && parameterControlCount === 1;
1215
1250
  }
1216
1251
 
1252
+ function shouldHideToggleParameterControlLabel({
1253
+ control,
1254
+ controlsById,
1255
+ layoutGroup,
1256
+ }: {
1257
+ control: ToolcraftControlSchema;
1258
+ controlsById: Record<string, ToolcraftControlSchema>;
1259
+ layoutGroup: ToolcraftControlLayoutGroupSchema | undefined;
1260
+ }): boolean {
1261
+ return Boolean(
1262
+ layoutGroup &&
1263
+ isToggleParameterLayoutGroup(controlsById, layoutGroup) &&
1264
+ !isBooleanControl(control),
1265
+ );
1266
+ }
1267
+
1217
1268
  function getControlTargetEntityKey(control: ToolcraftControlSchema): string | null {
1218
1269
  const parts = control.target.split(".");
1219
1270
 
@@ -1409,6 +1460,7 @@ function getInlineLayoutGroupByControlId({
1409
1460
  if (
1410
1461
  hasColorOpacityControl(controlsById, layoutGroup) ||
1411
1462
  hasSliderControl(controlsById, layoutGroup) ||
1463
+ hasSegmentedControl(controlsById, layoutGroup) ||
1412
1464
  hasSectionedCompoundControl(controlsById, layoutGroup) ||
1413
1465
  hasUnsafeInlineSwitchLabels(controlsById, layoutGroup)
1414
1466
  ) {
@@ -1576,6 +1628,14 @@ export function ControlsPanel({
1576
1628
  function getControlValue(control: ToolcraftControlSchema): unknown {
1577
1629
  const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(control.target);
1578
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
+
1579
1639
  return canvasSizeDimension
1580
1640
  ? state.canvas.size[canvasSizeDimension]
1581
1641
  : (state.values[control.target] ?? control.defaultValue);
@@ -1596,6 +1656,14 @@ export function ControlsPanel({
1596
1656
  function getTargetValue(target: string): unknown {
1597
1657
  const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(target);
1598
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
+
1599
1667
  return canvasSizeDimension
1600
1668
  ? state.canvas.size[canvasSizeDimension]
1601
1669
  : (state.values[target] ?? getControlDefaultValueByTarget(target));
@@ -1893,6 +1961,10 @@ export function ControlsPanel({
1893
1961
  providerKey: string;
1894
1962
  sectionTitle: string | undefined;
1895
1963
  }): React.ReactNode {
1964
+ if (control.label === false) {
1965
+ return children;
1966
+ }
1967
+
1896
1968
  const help = getControlHelpText({ control, label, sectionTitle });
1897
1969
 
1898
1970
  if (!help) {
@@ -2329,14 +2401,17 @@ export function ControlsPanel({
2329
2401
  {visibleSections.map(({ entries, section }, sectionIndex) => {
2330
2402
  const visibleControls = getControlsRecord(entries);
2331
2403
  const sectionHasOnlyColorFields = isColorOnlySection(entries);
2332
- const renderedSectionTitle = getRenderedControlsSectionTitle(section);
2333
- 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";
2334
2409
  const sectionCollapseKey = getControlsPanelSectionCollapseKey({
2335
2410
  entries,
2336
2411
  section,
2337
2412
  sectionIndex,
2338
2413
  });
2339
- const isSectionCollapsible = renderedSectionTitle !== undefined;
2414
+ const isSectionCollapsible = !isRuntimeSetup && renderedSectionTitle !== undefined;
2340
2415
  const isSectionCollapsed =
2341
2416
  isSectionCollapsible && collapsedSectionByKey[sectionCollapseKey] === true;
2342
2417
  const headerKeyframeEntry = getSectionHeaderKeyframeEntry(entries, section.title);
@@ -2404,13 +2479,23 @@ export function ControlsPanel({
2404
2479
  }
2405
2480
 
2406
2481
  const [id, rawControl] = group.entry;
2407
- const isInInlineLayoutGroup = inlineLayoutGroupByControlId.has(id);
2482
+ const inlineLayoutGroup = inlineLayoutGroupByControlId.get(id);
2483
+ const isInInlineLayoutGroup = Boolean(inlineLayoutGroup);
2408
2484
  const disabled = isControlDisabled(rawControl);
2409
- const control =
2485
+ const resolvedControl =
2410
2486
  disabled === Boolean(rawControl.disabled)
2411
2487
  ? rawControl
2412
2488
  : { ...rawControl, disabled };
2413
- const name = getControlName(id, control.label);
2489
+ const shouldHideLabel = shouldHideToggleParameterControlLabel({
2490
+ control: resolvedControl,
2491
+ controlsById: visibleControls,
2492
+ layoutGroup: inlineLayoutGroup,
2493
+ });
2494
+ const control =
2495
+ shouldHideLabel && resolvedControl.label !== false
2496
+ ? { ...resolvedControl, label: false }
2497
+ : resolvedControl;
2498
+ const name = getControlName(id, resolvedControl.label);
2414
2499
  const value = getControlValue(control);
2415
2500
  const usesHeaderKeyframeAction = control.target === headerKeyframeTarget;
2416
2501
  const commitWithLabel =
@@ -2522,6 +2607,7 @@ export function ControlsPanel({
2522
2607
  key={id}
2523
2608
  name={name}
2524
2609
  onValueChange={commit}
2610
+ showLabel={control.label !== false}
2525
2611
  value={asString(value)}
2526
2612
  />
2527
2613
  ),
@@ -2624,6 +2710,7 @@ export function ControlsPanel({
2624
2710
  id: asset.id,
2625
2711
  size: asset.size,
2626
2712
  src: asset.dataUrl,
2713
+ transform: asset.transform,
2627
2714
  }));
2628
2715
  const previewMediaIds = previewMediaAssets.map((asset) => asset.id);
2629
2716
  const importFile = (file: File, replaceExisting: boolean): void => {
@@ -2733,6 +2820,17 @@ export function ControlsPanel({
2733
2820
  type: "media.reorder",
2734
2821
  });
2735
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
+ }}
2736
2834
  preview={
2737
2835
  previewMediaAsset
2738
2836
  ? {
@@ -2740,6 +2838,7 @@ export function ControlsPanel({
2740
2838
  alt: previewMediaAsset.fileName,
2741
2839
  size: previewMediaAsset.size,
2742
2840
  src: previewMediaAsset.dataUrl,
2841
+ transform: previewMediaAsset.transform,
2743
2842
  }
2744
2843
  : undefined
2745
2844
  }
@@ -2814,7 +2913,7 @@ export function ControlsPanel({
2814
2913
  : undefined
2815
2914
  }
2816
2915
  key={id}
2817
- onCommit={(nextValue) => commit(nextValue)}
2916
+ onValueChange={commit}
2818
2917
  value={
2819
2918
  isRecord(value)
2820
2919
  ? (value as React.ComponentProps<typeof Palette>["value"])
@@ -2835,7 +2934,7 @@ export function ControlsPanel({
2835
2934
  return (
2836
2935
  <PanelActions
2837
2936
  actions={actions.map((action) => ({
2838
- icon: action.icon,
2937
+ icon: getPanelActionIcon(action),
2839
2938
  name: getActionLabel(action),
2840
2939
  value: action.value,
2841
2940
  variant: getPanelActionButtonVariant(action.variant),
@@ -2863,6 +2962,7 @@ export function ControlsPanel({
2863
2962
  key={id}
2864
2963
  name={name}
2865
2964
  onValueChange={commit}
2965
+ showLabel={control.label !== false}
2866
2966
  start={rangeValue.start}
2867
2967
  />
2868
2968
  ),
@@ -2925,10 +3025,10 @@ export function ControlsPanel({
2925
3025
  return (
2926
3026
  <Select
2927
3027
  key={id}
2928
- layout={isInInlineLayoutGroup ? "stacked" : undefined}
2929
3028
  name={name}
2930
3029
  onValueChange={commit}
2931
3030
  options={control.options ?? []}
3031
+ showLabel={control.label !== false}
2932
3032
  value={asString(value, control.options?.[0]?.value ?? "")}
2933
3033
  />
2934
3034
  );
@@ -3008,6 +3108,7 @@ export function ControlsPanel({
3008
3108
  key={id}
3009
3109
  name={name}
3010
3110
  onValueChange={commit}
3111
+ showLabel={control.label !== false}
3011
3112
  value={asString(value)}
3012
3113
  />
3013
3114
  ),
@@ -3020,6 +3121,7 @@ export function ControlsPanel({
3020
3121
 
3021
3122
  case "vector": {
3022
3123
  const vectorValue = asVectorValue(value);
3124
+ const padVariant = asVectorPadVariant(control.variant);
3023
3125
 
3024
3126
  return withKeyframeLabelAction({
3025
3127
  children: (
@@ -3028,8 +3130,12 @@ export function ControlsPanel({
3028
3130
  key={id}
3029
3131
  name={name}
3030
3132
  onValueChange={commit}
3133
+ padCoordinateMode={asVectorPadCoordinateMode(
3134
+ control.coordinateMode,
3135
+ padVariant,
3136
+ )}
3031
3137
  padShape={vectorPadShape}
3032
- padVariant={asVectorPadVariant(control.variant)}
3138
+ padVariant={padVariant}
3033
3139
  x={vectorValue.x}
3034
3140
  xLabel={control.xLabel}
3035
3141
  y={vectorValue.y}
@@ -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
  );