diff --git a/packages/brand/src/3DContext/Div3D.tsx b/packages/brand/src/3DContext/Div3D.tsx index 7d85e4d5c2f..8b75038fcc4 100644 --- a/packages/brand/src/3DContext/Div3D.tsx +++ b/packages/brand/src/3DContext/Div3D.tsx @@ -129,6 +129,7 @@ const extrudeDivSchema = { scaleY: scaleTransformField('Scale Y'), scaleZ: scaleTransformField('Scale Z'), ...Interactive.transformSchema, + ...Interactive.borderSchema, } as const satisfies InteractivitySchema; const setRef = ( diff --git a/packages/brand/src/Compose/WhatIsRemotion.tsx b/packages/brand/src/Compose/WhatIsRemotion.tsx index f93e25b33d0..8f867500df3 100644 --- a/packages/brand/src/Compose/WhatIsRemotion.tsx +++ b/packages/brand/src/Compose/WhatIsRemotion.tsx @@ -43,6 +43,7 @@ const labelSchema = { ...Interactive.baseSchema, ...Interactive.transformSchema, ...Interactive.textSchema, + ...Interactive.borderSchema, children: { type: 'text-content', default: '', diff --git a/packages/core/src/HtmlInCanvas.tsx b/packages/core/src/HtmlInCanvas.tsx index a64b2611106..60edc7e0d0d 100644 --- a/packages/core/src/HtmlInCanvas.tsx +++ b/packages/core/src/HtmlInCanvas.tsx @@ -19,6 +19,7 @@ import {addSequenceStackTraces} from './enable-sequence-stack-traces.js'; import type {InteractiveBaseProps} from './Interactive.js'; import { baseSchema, + borderSchema, transformSchema, type InteractivitySchema, } from './interactivity-schema.js'; @@ -784,6 +785,7 @@ export const htmlInCanvasSchema = { hiddenFromList: false, }, ...transformSchema, + ...borderSchema, } as const satisfies InteractivitySchema; const HtmlInCanvasWrapped = withInteractivitySchema({ diff --git a/packages/core/src/Img.tsx b/packages/core/src/Img.tsx index f1a77887f01..fdca6eee722 100644 --- a/packages/core/src/Img.tsx +++ b/packages/core/src/Img.tsx @@ -19,6 +19,7 @@ import {getCrossOriginValue} from './get-cross-origin-value.js'; import type {InteractiveBaseProps} from './Interactive.js'; import { baseSchema, + borderSchema, transformSchema, type InteractivitySchema, } from './interactivity-schema.js'; @@ -387,6 +388,7 @@ export const imgSchema = { }, ...baseSchema, ...transformSchema, + ...borderSchema, } as const satisfies InteractivitySchema; const imgCanvasFallbackIncompatibleProps = new Set([ diff --git a/packages/core/src/Interactive.tsx b/packages/core/src/Interactive.tsx index 2a5badf562a..47dbc7469d2 100644 --- a/packages/core/src/Interactive.tsx +++ b/packages/core/src/Interactive.tsx @@ -6,6 +6,7 @@ import type { import {addSequenceStackTraces} from './enable-sequence-stack-traces.js'; import { baseSchema, + borderSchema, premountSchema, sequenceSchema, textContentSchema, @@ -141,7 +142,18 @@ const interactiveElementSchema = { ...transformSchema, } as const satisfies InteractivitySchema; +const interactiveBorderElementSchema = { + ...interactiveElementSchema, + ...borderSchema, +} as const satisfies InteractivitySchema; + const interactiveTextElementSchema = { + ...interactiveBorderElementSchema, + ...textSchema, + ...textContentSchema, +} as const satisfies InteractivitySchema; + +const interactiveSvgTextElementSchema = { ...interactiveElementSchema, ...textSchema, ...textContentSchema, @@ -266,6 +278,7 @@ export const Interactive = { baseSchema, transformSchema, textSchema, + borderSchema, premountSchema, sequenceSchema, withSchema, @@ -302,8 +315,16 @@ export const Interactive = { Small: makeInteractiveTextElement('small', ''), Span: makeInteractiveTextElement('span', ''), Strong: makeInteractiveTextElement('strong', ''), - Svg: makeInteractiveNonTextElement('svg', ''), - Text: makeInteractiveTextElement('text', ''), + Svg: makeInteractiveElement( + 'svg', + '', + interactiveBorderElementSchema, + ), + Text: makeInteractiveElement( + 'text', + '', + interactiveSvgTextElementSchema, + ), Ul: makeInteractiveTextElement('ul', ''), }; diff --git a/packages/core/src/animated-image/AnimatedImage.tsx b/packages/core/src/animated-image/AnimatedImage.tsx index cf2dd10aeb9..15ef785b052 100644 --- a/packages/core/src/animated-image/AnimatedImage.tsx +++ b/packages/core/src/animated-image/AnimatedImage.tsx @@ -17,6 +17,7 @@ import { import {addSequenceStackTraces} from '../enable-sequence-stack-traces.js'; import { baseSchema, + borderSchema, transformSchema, type InteractivitySchema, } from '../interactivity-schema.js'; @@ -38,7 +39,7 @@ import type { import {serializeRequestInit} from './request-init'; import {resolveAnimatedImageSource} from './resolve-image-source'; -const animatedImageSchema = { +export const animatedImageSchema = { src: { type: 'asset', default: undefined, @@ -57,6 +58,7 @@ const animatedImageSchema = { keyframable: false, }, ...transformSchema, + ...borderSchema, } as const satisfies InteractivitySchema; const getCanvasPropsFromSequenceProps = ( diff --git a/packages/core/src/canvas-image/CanvasImage.tsx b/packages/core/src/canvas-image/CanvasImage.tsx index 5329f8a92b8..f45ae62515a 100644 --- a/packages/core/src/canvas-image/CanvasImage.tsx +++ b/packages/core/src/canvas-image/CanvasImage.tsx @@ -22,6 +22,7 @@ import {addSequenceStackTraces} from '../enable-sequence-stack-traces.js'; import {Freeze} from '../freeze.js'; import { baseSchema, + borderSchema, premountSchema, premountStyleSchema, transformSchema, @@ -52,6 +53,7 @@ export const canvasImageSchema = { }, }, ...transformSchema, + ...borderSchema, } as const satisfies InteractivitySchema; type LoadedImage = { diff --git a/packages/core/src/effects/Solid.tsx b/packages/core/src/effects/Solid.tsx index bfe92411117..0efce8f1690 100644 --- a/packages/core/src/effects/Solid.tsx +++ b/packages/core/src/effects/Solid.tsx @@ -12,6 +12,7 @@ import {addSequenceStackTraces} from '../enable-sequence-stack-traces.js'; import type {InteractiveBaseProps} from '../Interactive.js'; import { baseSchema, + borderSchema, transformSchema, type InteractivitySchema, } from '../interactivity-schema.js'; @@ -96,6 +97,7 @@ export const solidSchema = { hiddenFromList: false, }, ...transformSchema, + ...borderSchema, } as const satisfies InteractivitySchema; const SolidInner: React.FC< diff --git a/packages/core/src/interactivity-schema.ts b/packages/core/src/interactivity-schema.ts index f772b94aebb..4ff59e5a347 100644 --- a/packages/core/src/interactivity-schema.ts +++ b/packages/core/src/interactivity-schema.ts @@ -333,6 +333,40 @@ export const textSchema = { }, } as const satisfies InteractivitySchema; +export const borderSchema = { + 'style.borderWidth': { + type: 'number', + default: undefined, + min: 0, + step: 1, + description: 'Border width', + hiddenFromList: false, + }, + 'style.borderStyle': { + type: 'enum', + // `none` is the CSS initial value of border-style. + default: 'none', + description: 'Border style', + variants: { + none: {}, + hidden: {}, + solid: {}, + dashed: {}, + dotted: {}, + double: {}, + groove: {}, + ridge: {}, + inset: {}, + outset: {}, + }, + }, + 'style.borderColor': { + type: 'color', + default: undefined, + description: 'Border color', + }, +} as const satisfies InteractivitySchema; + export const textContentSchema = { children: { type: 'text-content', @@ -378,6 +412,7 @@ export const sequencePremountSchema = { export const sequenceStyleSchema = { ...transformSchema, + ...borderSchema, ...sequencePremountSchema, } as const satisfies InteractivitySchema; diff --git a/packages/core/src/test/find-props-to-delete.test.ts b/packages/core/src/test/find-props-to-delete.test.ts index 6f3b6c43995..beda397e494 100644 --- a/packages/core/src/test/find-props-to-delete.test.ts +++ b/packages/core/src/test/find-props-to-delete.test.ts @@ -39,6 +39,9 @@ test('find right values to delete when upgrading a discriminated union', () => { 'style.scale', 'style.rotate', 'style.opacity', + 'style.borderWidth', + 'style.borderStyle', + 'style.borderColor', 'premountFor', 'postmountFor', 'styleWhilePremounted', diff --git a/packages/core/src/test/with-interactivity-schema-helpers.test.ts b/packages/core/src/test/with-interactivity-schema-helpers.test.ts index 44f52d1cd6e..38bb75cfb98 100644 --- a/packages/core/src/test/with-interactivity-schema-helpers.test.ts +++ b/packages/core/src/test/with-interactivity-schema-helpers.test.ts @@ -1,4 +1,6 @@ import {expect, test} from 'bun:test'; +import {animatedImageSchema} from '../animated-image/AnimatedImage.js'; +import {canvasImageSchema} from '../canvas-image/CanvasImage.js'; import type {SequenceControls} from '../CompositionManager.js'; import {solidSchema} from '../effects/Solid.js'; import {getComponentsToAddStacksTo} from '../enable-sequence-stack-traces.js'; @@ -7,9 +9,11 @@ import { getFlatSchemaWithAllKeys, } from '../flatten-schema.js'; import {htmlInCanvasSchema} from '../HtmlInCanvas.js'; +import {imgSchema} from '../Img.js'; import {Interactive} from '../Interactive.js'; import { baseSchema, + borderSchema, extendSchemaWithSequenceName, premountSchema, sequencePremountSchema, @@ -31,11 +35,27 @@ test('sequenceStyleSchema contains transform and premount fields', () => { expect(Object.keys(sequenceStyleSchema).sort()).toEqual( [ ...Object.keys(transformSchema), + ...Object.keys(borderSchema), ...Object.keys(sequencePremountSchema), ].sort(), ); }); +test('CSS box component schemas expose border controls', () => { + for (const schema of [ + sequenceStyleSchema, + imgSchema, + animatedImageSchema, + canvasImageSchema, + htmlInCanvasSchema, + solidSchema, + ]) { + expect('style.borderWidth' in schema).toBe(true); + expect('style.borderStyle' in schema).toBe(true); + expect('style.borderColor' in schema).toBe(true); + } +}); + test('premount fields are not keyframable', () => { expect(Object.keys(premountSchema).sort()).toEqual( ['postmountFor', 'premountFor'].sort(), @@ -126,6 +146,9 @@ test('getFlatSchema(sequenceSchema) exposes every variant key', () => { 'style.rotate', 'style.transformOrigin', 'style.opacity', + 'style.borderWidth', + 'style.borderStyle', + 'style.borderColor', 'premountFor', 'postmountFor', 'styleWhilePremounted', @@ -300,6 +323,39 @@ test('textSchema exposes common text style fields', () => { }); }); +test('borderSchema exposes the longhand border style fields', () => { + expect(Object.keys(borderSchema).sort()).toEqual( + ['style.borderColor', 'style.borderStyle', 'style.borderWidth'].sort(), + ); + expect(borderSchema['style.borderWidth']).toMatchObject({ + type: 'number', + default: undefined, + min: 0, + step: 1, + hiddenFromList: false, + }); + expect(borderSchema['style.borderStyle']).toMatchObject({ + type: 'enum', + default: 'none', + }); + expect(Object.keys(borderSchema['style.borderStyle'].variants)).toEqual([ + 'none', + 'hidden', + 'solid', + 'dashed', + 'dotted', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', + ]); + expect(borderSchema['style.borderColor']).toMatchObject({ + type: 'color', + default: undefined, + }); +}); + test('readValuesFromProps reads dot-notation keys via getNestedValue', () => { const props = { layout: 'absolute-fill', @@ -364,6 +420,9 @@ test('selectActiveKeys exposes style.* keys when layout=absolute-fill', () => { 'style.rotate', 'style.transformOrigin', 'style.opacity', + 'style.borderWidth', + 'style.borderStyle', + 'style.borderColor', 'premountFor', 'postmountFor', ].sort(), diff --git a/packages/docs/docs/interactive.mdx b/packages/docs/docs/interactive.mdx index 8fed623bc8e..b03a247205c 100644 --- a/packages/docs/docs/interactive.mdx +++ b/packages/docs/docs/interactive.mdx @@ -102,6 +102,16 @@ Controls for text-related style props: Use it for components that accept a `style` prop and render text. +### `Interactive.borderSchema` + +Controls for border-related style props: + +`style.borderWidth`, `style.borderStyle` and `style.borderColor`. + +The longhand properties are used rather than the `border` shorthand, because React does not expand shorthands. This lets each control read its own value, and allows `style.borderWidth` and `style.borderColor` to be animated. + +Use it for components that accept a `style` prop and render a border. + ### `Interactive.premountSchema` Controls for mounting behavior: diff --git a/packages/docs/docs/interactivity-schema.mdx b/packages/docs/docs/interactivity-schema.mdx index 9386fd0bebc..65422c57711 100644 --- a/packages/docs/docs/interactivity-schema.mdx +++ b/packages/docs/docs/interactivity-schema.mdx @@ -49,6 +49,12 @@ Text-related style props: `style.color`, `style.fontFamily`, `style.fontSize`, ` `style.fontFamily` is available from . +### `Interactive.borderSchema` + +Border-related style props: `style.borderWidth`, `style.borderStyle` and `style.borderColor`. + +The longhand properties are used rather than the `border` shorthand, because React does not expand shorthands. This lets each control read its own value, and allows `style.borderWidth` and `style.borderColor` to be animated. + ### `Interactive.premountSchema` Mounting props: `premountFor`, `postmountFor`, `styleWhilePremounted` and `styleWhilePostmounted`. diff --git a/packages/docs/elements/overlays/lower-third/lower-third.tsx b/packages/docs/elements/overlays/lower-third/lower-third.tsx index c3880bd2683..56f9f561e4d 100644 --- a/packages/docs/elements/overlays/lower-third/lower-third.tsx +++ b/packages/docs/elements/overlays/lower-third/lower-third.tsx @@ -24,7 +24,9 @@ export const NameLowerThird: React.FC = () => { fontFamily: 'Inter', backgroundColor: 'rgba(255, 255, 255, 0.94)', boxShadow: '0 6px 12px rgba(24, 24, 27, 0.2)', - border: '1px solid rgba(24, 24, 27, 0.08)', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'rgba(24, 24, 27, 0.08)', opacity: interpolate(frame, [0, 18, 102, 119], [0, 1, 1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', diff --git a/packages/gif/src/Gif.tsx b/packages/gif/src/Gif.tsx index 898074ca141..e02e131af7c 100644 --- a/packages/gif/src/Gif.tsx +++ b/packages/gif/src/Gif.tsx @@ -26,7 +26,7 @@ export type GifProps = InteractiveBaseProps & * @description Displays a GIF that synchronizes with Remotions useCurrentFrame(). * @see [Documentation](https://remotion.dev/docs/gif) */ -const gifSchema = { +export const gifSchema: InteractivitySchema = { ...Internals.baseSchema, playbackRate: { type: 'number', @@ -39,6 +39,7 @@ const gifSchema = { keyframable: false, }, ...Internals.transformSchema, + ...Interactive.borderSchema, } as const satisfies InteractivitySchema; const GifInner = ({ diff --git a/packages/gif/src/test/Gif.test.tsx b/packages/gif/src/test/Gif.test.tsx index 6adf79cc2f8..6afd0b6d2a6 100644 --- a/packages/gif/src/test/Gif.test.tsx +++ b/packages/gif/src/test/Gif.test.tsx @@ -2,7 +2,7 @@ import {afterEach, beforeEach, expect, test} from 'bun:test'; import {cleanup, render, waitFor} from '@testing-library/react'; import React, {useCallback, useMemo} from 'react'; import {Internals} from 'remotion'; -import {Gif} from '../Gif'; +import {Gif, gifSchema} from '../Gif'; import {manuallyManagedGifCache} from '../gif-cache'; import type {GifState} from '../props'; import {getGifCacheKey} from '../request-init'; @@ -12,6 +12,12 @@ type RegisteredSequence = { readonly refForOutline: React.RefObject | null; }; +test('Gif exposes border controls', () => { + expect('style.borderWidth' in gifSchema).toBe(true); + expect('style.borderStyle' in gifSchema).toBe(true); + expect('style.borderColor' in gifSchema).toBe(true); +}); + class MockWorker { public static instances = 0; public constructor() { diff --git a/packages/light-leaks/src/LightLeak.tsx b/packages/light-leaks/src/LightLeak.tsx index cf463ca2b25..392e43ac831 100644 --- a/packages/light-leaks/src/LightLeak.tsx +++ b/packages/light-leaks/src/LightLeak.tsx @@ -235,7 +235,7 @@ const LightLeakCanvas: React.FC<{ * @description Renders a WebGL-based light leak effect as a Sequence. * @see [Documentation](https://www.remotion.dev/docs/light-leaks/light-leak) */ -const lightLeakSchema = { +export const lightLeakSchema: InteractivitySchema = { ...Internals.baseSchema, seed: { type: 'number', @@ -252,6 +252,7 @@ const lightLeakSchema = { hiddenFromList: false, }, ...Internals.transformSchema, + ...Interactive.borderSchema, ...Internals.premountSchema, } as const satisfies InteractivitySchema; diff --git a/packages/light-leaks/src/test/light-leak-effect-params.test.ts b/packages/light-leaks/src/test/light-leak-effect-params.test.ts index 9a4a58d7a89..fa264856bf3 100644 --- a/packages/light-leaks/src/test/light-leak-effect-params.test.ts +++ b/packages/light-leaks/src/test/light-leak-effect-params.test.ts @@ -1,5 +1,12 @@ import {expect, test} from 'bun:test'; import {LightLeakInternals, lightLeak} from '../light-leak-internals.js'; +import {lightLeakSchema} from '../LightLeak.js'; + +test(' exposes border controls', () => { + expect('style.borderWidth' in lightLeakSchema).toBe(true); + expect('style.borderStyle' in lightLeakSchema).toBe(true); + expect('style.borderColor' in lightLeakSchema).toBe(true); +}); test('lightLeak() accepts default params', () => { expect(() => lightLeak()).not.toThrow(); diff --git a/packages/media/src/audio/audio.tsx b/packages/media/src/audio/audio.tsx index d104c7aa169..eab3272a8ed 100644 --- a/packages/media/src/audio/audio.tsx +++ b/packages/media/src/audio/audio.tsx @@ -16,7 +16,7 @@ import type {AudioProps} from './props'; const {validateMediaProps} = Internals; -const audioSchema = { +export const audioSchema: InteractivitySchema = { src: { type: 'asset', default: undefined, diff --git a/packages/media/src/test/border-schema.test.ts b/packages/media/src/test/border-schema.test.ts new file mode 100644 index 00000000000..9dff257ab97 --- /dev/null +++ b/packages/media/src/test/border-schema.test.ts @@ -0,0 +1,15 @@ +import {expect, test} from 'vitest'; +import {audioSchema} from '../audio/audio'; +import {videoSchema} from '../video/video'; + +test('Video exposes border controls', () => { + expect('style.borderWidth' in videoSchema).toBe(true); + expect('style.borderStyle' in videoSchema).toBe(true); + expect('style.borderColor' in videoSchema).toBe(true); +}); + +test('Audio does not expose visual border controls', () => { + expect('style.borderWidth' in audioSchema).toBe(false); + expect('style.borderStyle' in audioSchema).toBe(false); + expect('style.borderColor' in audioSchema).toBe(false); +}); diff --git a/packages/media/src/video/video.tsx b/packages/media/src/video/video.tsx index d16638d16c2..e4f5bc61bfa 100644 --- a/packages/media/src/video/video.tsx +++ b/packages/media/src/video/video.tsx @@ -18,7 +18,7 @@ import {VideoForRendering} from './video-for-rendering'; const {validateMediaTrimProps, resolveTrimProps, validateMediaProps} = Internals; -const videoSchema = { +export const videoSchema: InteractivitySchema = { src: { type: 'asset', default: undefined, @@ -49,6 +49,7 @@ const videoSchema = { muted: {type: 'boolean', default: false, description: 'Muted'}, loop: {type: 'boolean', default: false, description: 'Loop'}, ...Internals.transformSchema, + ...Interactive.borderSchema, } as const satisfies InteractivitySchema; const InnerVideo: React.FC< diff --git a/packages/rive/src/RemotionRiveCanvas.tsx b/packages/rive/src/RemotionRiveCanvas.tsx index df3d803656e..5d7938a13d7 100644 --- a/packages/rive/src/RemotionRiveCanvas.tsx +++ b/packages/rive/src/RemotionRiveCanvas.tsx @@ -109,6 +109,7 @@ const riveCanvasSchema = { variants: riveAlignmentVariants, }, ...Internals.transformSchema, + ...Interactive.borderSchema, } as const satisfies InteractivitySchema; type RemotionRiveCanvasContentProps = Omit< diff --git a/packages/rough-notation/src/Annotation.tsx b/packages/rough-notation/src/Annotation.tsx index f22ac79f319..05bb7787f06 100644 --- a/packages/rough-notation/src/Annotation.tsx +++ b/packages/rough-notation/src/Annotation.tsx @@ -235,6 +235,7 @@ const sharedSchema = (defaultRoughness: number): InteractivitySchema => ({ ...colorSchema, ...Interactive.textSchema, ...textContentSchema, + ...Interactive.borderSchema, }); export const highlightSchema: InteractivitySchema = { diff --git a/packages/rough-notation/src/test/annotation-schema.test.ts b/packages/rough-notation/src/test/annotation-schema.test.ts index 54e8259a0ec..504149a87c9 100644 --- a/packages/rough-notation/src/test/annotation-schema.test.ts +++ b/packages/rough-notation/src/test/annotation-schema.test.ts @@ -57,7 +57,7 @@ test('component-specific controls are isolated to their schemas', () => { expect(keys(boxSchema)).not.toContain('bracketRight'); }); -test('all named annotations expose text, font, and seed controls', () => { +test('all named annotations expose text, font, border, and seed controls', () => { for (const schema of annotationSchemas) { expect(keys(schema)).toContain('children'); expect(keys(schema)).toContain('style.fontFamily'); @@ -65,6 +65,9 @@ test('all named annotations expose text, font, and seed controls', () => { expect(keys(schema)).toContain('style.fontWeight'); expect(keys(schema)).toContain('style.fontStyle'); expect(keys(schema)).toContain('style.letterSpacing'); + expect(keys(schema)).toContain('style.borderWidth'); + expect(keys(schema)).toContain('style.borderStyle'); + expect(keys(schema)).toContain('style.borderColor'); expect(schema.seed).not.toHaveProperty('keyframable', false); } }); diff --git a/packages/shapes/src/components/schema.ts b/packages/shapes/src/components/schema.ts index dc18809465f..55378b48e8d 100644 --- a/packages/shapes/src/components/schema.ts +++ b/packages/shapes/src/components/schema.ts @@ -1,5 +1,6 @@ import { Internals, + Interactive, type InteractivitySchemaField, type InteractivitySchema, } from 'remotion'; @@ -88,5 +89,6 @@ export const makeShapeSchema = ( description: 'Fill', }), ...Internals.transformSchema, + ...Interactive.borderSchema, }; }; diff --git a/packages/shapes/src/test/effects.test.tsx b/packages/shapes/src/test/effects.test.tsx index 8148292e314..378c2f6f5a5 100644 --- a/packages/shapes/src/test/effects.test.tsx +++ b/packages/shapes/src/test/effects.test.tsx @@ -117,6 +117,11 @@ mock.module('remotion', () => { useMemoizedEffectDefinitions: mock(() => effectDefinitions), }, Interactive: { + borderSchema: { + 'style.borderWidth': {}, + 'style.borderStyle': {}, + 'style.borderColor': {}, + }, withSchema: mock(({Component}) => Component), }, Sequence: ({ diff --git a/packages/shapes/src/test/schema.test.ts b/packages/shapes/src/test/schema.test.ts index b08516b9179..4301c344526 100644 --- a/packages/shapes/src/test/schema.test.ts +++ b/packages/shapes/src/test/schema.test.ts @@ -2,9 +2,13 @@ import {expect, test} from 'bun:test'; import {makeShapeSchema} from '../components/schema'; test('adds a fill control to shape schemas', () => { - expect(makeShapeSchema({}).fill).toEqual({ + const schema = makeShapeSchema({}); + expect(schema.fill).toEqual({ type: 'color', default: '#0b84ff', description: 'Fill', }); + expect('style.borderWidth' in schema).toBe(true); + expect('style.borderStyle' in schema).toBe(true); + expect('style.borderColor' in schema).toBe(true); }); diff --git a/packages/starburst/src/Starburst.tsx b/packages/starburst/src/Starburst.tsx index 5de2f9f83dc..9c1a5da0b66 100644 --- a/packages/starburst/src/Starburst.tsx +++ b/packages/starburst/src/Starburst.tsx @@ -344,6 +344,7 @@ export const starburstSchema: InteractivitySchema = { hiddenFromList: false, }, ...Internals.transformSchema, + ...Interactive.borderSchema, ...Internals.premountSchema, }; diff --git a/packages/starburst/src/test/starburst-effect-params.test.ts b/packages/starburst/src/test/starburst-effect-params.test.ts index 5d735480a1b..c7758152b6a 100644 --- a/packages/starburst/src/test/starburst-effect-params.test.ts +++ b/packages/starburst/src/test/starburst-effect-params.test.ts @@ -97,6 +97,12 @@ test(' exposes colors as an array control', () => { }); }); +test(' exposes border controls', () => { + expect('style.borderWidth' in starburstSchema).toBe(true); + expect('style.borderStyle' in starburstSchema).toBe(true); + expect('style.borderColor' in starburstSchema).toBe(true); +}); + test('starburst() parameters produce distinct effect keys', () => { const defaultStarburst = starburst({ rays: 12, diff --git a/packages/studio-server/src/codemods/update-sequence-props/update-sequence-props.ts b/packages/studio-server/src/codemods/update-sequence-props/update-sequence-props.ts index b6d93906681..221ceb8b35d 100644 --- a/packages/studio-server/src/codemods/update-sequence-props/update-sequence-props.ts +++ b/packages/studio-server/src/codemods/update-sequence-props/update-sequence-props.ts @@ -4,6 +4,7 @@ import type { JSXExpressionContainer, JSXFragment, JSXSpreadAttribute, + ObjectProperty, StringLiteral, Expression, File, @@ -18,6 +19,10 @@ import type { VideoConfigValues, } from 'remotion'; import {NoReactInternals} from 'remotion/no-react'; +import { + getCssShorthandsForUpdates, + type CssShorthandProperty, +} from '../../helpers/css-shorthand-properties'; import { parseVideoConfigNumericExpression, updateVideoConfigNumericExpression, @@ -748,6 +753,75 @@ const applyGoogleFontSourceEdits = ({ } }; +const migrateCssShorthand = ({ + node, + cssShorthand, +}: { + node: JSXOpeningElementLike; + cssShorthand: CssShorthandProperty; +}) => { + const styleAttribute = node.attributes?.find( + (attribute) => + attribute.type === 'JSXAttribute' && + attribute.name.type === 'JSXIdentifier' && + attribute.name.name === 'style', + ); + if ( + styleAttribute?.type !== 'JSXAttribute' || + styleAttribute.value?.type !== 'JSXExpressionContainer' || + styleAttribute.value.expression.type !== 'ObjectExpression' + ) { + return; + } + + const {properties} = styleAttribute.value.expression; + for (let index = 0; index < properties.length; index++) { + const property = properties[index]; + if ( + property.type !== 'ObjectProperty' || + !( + (property.key.type === 'Identifier' && + property.key.name === cssShorthand.shorthand) || + (property.key.type === 'StringLiteral' && + property.key.value === cssShorthand.shorthand) + ) + ) { + continue; + } + + const shorthandValue = + property.value.type === 'StringLiteral' + ? property.value.value + : property.value.type === 'TemplateLiteral' && + property.value.expressions.length === 0 + ? (property.value.quasis[0]?.value.cooked ?? null) + : null; + if (shorthandValue === null) { + continue; + } + + const parsed = cssShorthand.parse(shorthandValue); + if (!parsed) { + continue; + } + + properties.splice( + index, + 1, + ...cssShorthand.longhands.map((longhand) => { + const value = parsed[longhand]; + return b.objectProperty( + b.identifier(longhand), + typeof value === 'number' + ? b.numericLiteral(value) + : b.stringLiteral(value), + ) as ObjectProperty; + }), + ); + index += cssShorthand.longhands.length - 1; + } +}; + const updateSequencePropsNode = ({ jsxElement, updates, @@ -765,6 +839,11 @@ const updateSequencePropsNode = ({ } => { const node = jsxElement.openingElement; const logLine = node.loc?.start.line ?? 1; + for (const cssShorthand of getCssShorthandsForUpdates( + updates.map((update) => update.key), + )) { + migrateCssShorthand({node, cssShorthand}); + } const oldValueStrings: string[] = []; const initialAttrs = snapshotTopLevelAttrs(node); diff --git a/packages/studio-server/src/helpers/css-shorthand-properties.ts b/packages/studio-server/src/helpers/css-shorthand-properties.ts new file mode 100644 index 00000000000..64061167f83 --- /dev/null +++ b/packages/studio-server/src/helpers/css-shorthand-properties.ts @@ -0,0 +1,57 @@ +import {parseBorderShorthand} from './parse-border-shorthand'; + +type ParsedCssShorthand = Readonly>; + +export type CssShorthandProperty = { + readonly parentKey: string; + readonly shorthand: string; + readonly longhands: readonly string[]; + readonly parse: (value: string) => ParsedCssShorthand | null; + readonly isUnsupportedProperty: (propertyName: string) => boolean; +}; + +const borderSidePropertyRegex = + /^border(?:Top|Right|Bottom|Left)(?:Width|Style|Color)?$/; + +const borderShorthand = { + parentKey: 'style', + shorthand: 'border', + longhands: ['borderWidth', 'borderStyle', 'borderColor'], + parse: parseBorderShorthand, + isUnsupportedProperty: (propertyName: string) => + borderSidePropertyRegex.test(propertyName), +} as const satisfies CssShorthandProperty; + +export const cssShorthandProperties = [ + borderShorthand, +] as const satisfies readonly CssShorthandProperty[]; + +export const getCssShorthandForLonghand = ({ + parentKey, + longhand, +}: { + parentKey: string; + longhand: string; +}): CssShorthandProperty | null => { + return ( + cssShorthandProperties.find( + (property) => + property.parentKey === parentKey && + property.longhands.includes( + longhand as (typeof property.longhands)[number], + ), + ) ?? null + ); +}; + +export const getCssShorthandsForUpdates = ( + updateKeys: readonly string[], +): CssShorthandProperty[] => { + return cssShorthandProperties.filter((property) => + updateKeys.some((key) => + property.longhands.some( + (longhand) => key === `${property.parentKey}.${longhand}`, + ), + ), + ); +}; diff --git a/packages/studio-server/src/helpers/parse-border-shorthand.ts b/packages/studio-server/src/helpers/parse-border-shorthand.ts new file mode 100644 index 00000000000..04df7a961c5 --- /dev/null +++ b/packages/studio-server/src/helpers/parse-border-shorthand.ts @@ -0,0 +1,169 @@ +export type ParsedBorderShorthand = { + borderWidth: number; + borderStyle: string; + borderColor: string; +}; + +const BORDER_STYLES = new Set([ + 'none', + 'hidden', + 'dotted', + 'dashed', + 'solid', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', +]); + +const CSS_WIDE_KEYWORDS = new Set([ + 'inherit', + 'initial', + 'revert', + 'revert-layer', + 'unset', +]); + +const BORDER_WIDTH_KEYWORDS: Record = { + // Chromium resolves these CSS keywords to these pixel values. + thin: 1, + medium: 3, + thick: 5, +}; + +const splitCssWhitespace = (value: string): string[] | null => { + const parts: string[] = []; + let current = ''; + let parenthesisDepth = 0; + let quote: '"' | "'" | null = null; + + for (const char of value.trim()) { + if (quote !== null) { + current += char; + if (char === quote) { + quote = null; + } + + continue; + } + + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + + if (char === '(') { + parenthesisDepth++; + current += char; + continue; + } + + if (char === ')') { + parenthesisDepth--; + if (parenthesisDepth < 0) { + return null; + } + + current += char; + continue; + } + + if (/\s/.test(char) && parenthesisDepth === 0) { + if (current !== '') { + parts.push(current); + current = ''; + } + + continue; + } + + current += char; + } + + if (quote !== null || parenthesisDepth !== 0) { + return null; + } + + if (current !== '') { + parts.push(current); + } + + return parts; +}; + +const parsePixelWidth = (token: string): number | null => { + if (token === '0') { + return 0; + } + + const match = token.match(/^(\d+(?:\.\d+)?|\.\d+)px$/i); + if (!match) { + return null; + } + + return Number(match[1]); +}; + +export const parseBorderShorthand = ( + value: string, +): ParsedBorderShorthand | null => { + const tokens = splitCssWhitespace(value); + if (!tokens || tokens.length === 0) { + return null; + } + + let borderWidth: number | null = null; + let borderStyle: string | null = null; + let borderColor: string | null = null; + + for (const token of tokens) { + const lowerCaseToken = token.toLowerCase(); + if (CSS_WIDE_KEYWORDS.has(lowerCaseToken)) { + return null; + } + + if (BORDER_STYLES.has(lowerCaseToken)) { + if (borderStyle !== null) { + return null; + } + + borderStyle = lowerCaseToken; + continue; + } + + const width = parsePixelWidth(token); + if (width !== null) { + if (borderWidth !== null) { + return null; + } + + borderWidth = width; + continue; + } + + const keywordWidth = BORDER_WIDTH_KEYWORDS[lowerCaseToken]; + if (keywordWidth !== undefined) { + if (borderWidth !== null) { + return null; + } + + borderWidth = keywordWidth; + continue; + } + + if (borderColor === null) { + borderColor = token; + continue; + } + + return null; + } + + return { + borderWidth: borderWidth ?? BORDER_WIDTH_KEYWORDS.medium, + borderStyle: borderStyle ?? 'none', + borderColor: borderColor ?? 'currentColor', + }; +}; diff --git a/packages/studio-server/src/preview-server/routes/can-update-sequence-props.ts b/packages/studio-server/src/preview-server/routes/can-update-sequence-props.ts index d1da6bca69c..98e0760d176 100644 --- a/packages/studio-server/src/preview-server/routes/can-update-sequence-props.ts +++ b/packages/studio-server/src/preview-server/routes/can-update-sequence-props.ts @@ -32,6 +32,7 @@ import type { } from 'remotion'; import {NoReactInternals} from 'remotion/no-react'; import {parseAst} from '../../codemods/parse-ast'; +import {getCssShorthandForLonghand} from '../../helpers/css-shorthand-properties'; import {getAstNodePath} from '../../helpers/get-ast-node-path'; import {toImportAgnosticNodePath} from '../../helpers/import-agnostic-node-path'; import {parseKeyframeEasingExpression} from '../../helpers/parse-keyframe-easing-expression'; @@ -946,6 +947,18 @@ const validateStyleValue = (childKey: string, value: unknown): boolean => { return true; }; +const getObjectPropertyName = (property: ObjectProperty): string | null => { + if (property.key.type === 'Identifier') { + return property.key.name; + } + + if (property.key.type === 'StringLiteral') { + return property.key.value; + } + + return null; +}; + const getNestedPropStatus = ({ jsxElement, ast, @@ -987,12 +1000,55 @@ const getNestedPropStatus = ({ } const objExpr = expression as ObjectExpression; - const prop = objExpr.properties.find( + const cssShorthand = getCssShorthandForLonghand({ + parentKey, + longhand: childKey, + }); + if ( + cssShorthand && + objExpr.properties.some((property) => { + if (property.type !== 'ObjectProperty') { + return false; + } + + const propertyName = getObjectPropertyName(property); + return ( + propertyName !== null && + cssShorthand.isUnsupportedProperty(propertyName) + ); + }) + ) { + return computedStatus(); + } + + const relevantProperties = objExpr.properties.filter( (p) => p.type === 'ObjectProperty' && - ((p.key.type === 'Identifier' && p.key.name === childKey) || - (p.key.type === 'StringLiteral' && p.key.value === childKey)), - ) as ObjectProperty | undefined; + (getObjectPropertyName(p) === childKey || + getObjectPropertyName(p) === cssShorthand?.shorthand), + ) as ObjectProperty[]; + const prop = relevantProperties.at(-1); + + if ( + prop && + cssShorthand && + getObjectPropertyName(prop) === cssShorthand.shorthand + ) { + const shorthandValue = prop.value as Expression; + if (!isStaticValue(shorthandValue, {allowSpecialValues: false})) { + return computedStatus(); + } + + const staticShorthandValue = extractStaticValue(shorthandValue, { + allowSpecialValues: false, + }); + if (typeof staticShorthandValue !== 'string') { + return computedStatus(); + } + + const parsed = cssShorthand.parse(staticShorthandValue); + return parsed ? staticStatus(parsed[childKey], null) : computedStatus(); + } if (!prop) { // Property not set in the object, can be added diff --git a/packages/studio-server/src/test/compute-sequence-props-status.test.ts b/packages/studio-server/src/test/compute-sequence-props-status.test.ts index 449f740882e..d70626e296a 100644 --- a/packages/studio-server/src/test/compute-sequence-props-status.test.ts +++ b/packages/studio-server/src/test/compute-sequence-props-status.test.ts @@ -138,6 +138,156 @@ export const Example: React.FC = () => { }); }); +test('computeSequencePropsStatus should expand a static border shorthand', () => { + const input = `import {AbsoluteFill} from 'remotion'; + +export const Example = () => { + return ; +}; +`; + const result = computeSequencePropsStatusFromContent({ + fileContents: input, + nodePath: getNodePathFromContent(input, 4), + componentIdentity: null, + keys: ['style.borderWidth', 'style.borderStyle', 'style.borderColor'], + effects: [], + videoConfigValues: null, + }); + + expect(result.props['style.borderWidth']).toEqual({ + status: 'static', + codeValue: 12, + }); + expect(result.props['style.borderStyle']).toEqual({ + status: 'static', + codeValue: 'dashed', + }); + expect(result.props['style.borderColor']).toEqual({ + status: 'static', + codeValue: 'rgba(1, 2, 3, 0.5)', + }); +}); + +test('computeSequencePropsStatus should not guess a dynamic border shorthand', () => { + const input = `import {AbsoluteFill} from 'remotion'; + +export const Example = ({border}: {border: string}) => { + return ; +}; +`; + const result = computeSequencePropsStatusFromContent({ + fileContents: input, + nodePath: getNodePathFromContent(input, 4), + componentIdentity: null, + keys: ['style.borderWidth', 'style.borderStyle', 'style.borderColor'], + effects: [], + videoConfigValues: null, + }); + + expect(result.props['style.borderWidth']).toEqual({status: 'computed'}); + expect(result.props['style.borderStyle']).toEqual({status: 'computed'}); + expect(result.props['style.borderColor']).toEqual({status: 'computed'}); +}); + +test('computeSequencePropsStatus should treat side-specific borders as computed', () => { + const sideProperties = [ + "borderLeft: '1px solid red'", + 'borderRightWidth: 2', + "borderTopStyle: 'dashed'", + "'borderBottomColor': 'blue'", + ]; + + for (const sideProperty of sideProperties) { + const input = `import {AbsoluteFill} from 'remotion'; + +export const Example = () => { + return ; +}; +`; + const result = computeSequencePropsStatusFromContent({ + fileContents: input, + nodePath: getNodePathFromContent(input, 4), + componentIdentity: null, + keys: ['style.borderWidth', 'style.borderStyle', 'style.borderColor'], + effects: [], + videoConfigValues: null, + }); + + expect(result.props['style.borderWidth']).toEqual({status: 'computed'}); + expect(result.props['style.borderStyle']).toEqual({status: 'computed'}); + expect(result.props['style.borderColor']).toEqual({status: 'computed'}); + } +}); + +test('computeSequencePropsStatus should respect border property order', () => { + const input = `import {AbsoluteFill} from 'remotion'; + +export const Example = () => { + return ( + <> + + + + ); +}; +`; + const beforeShorthand = computeSequencePropsStatusFromContent({ + fileContents: input, + nodePath: getNodePathFromContent(input, 6), + componentIdentity: null, + keys: ['style.borderWidth'], + effects: [], + videoConfigValues: null, + }); + const afterShorthand = computeSequencePropsStatusFromContent({ + fileContents: input, + nodePath: getNodePathFromContent(input, 7), + componentIdentity: null, + keys: ['style.borderWidth'], + effects: [], + videoConfigValues: null, + }); + + expect(beforeShorthand.props['style.borderWidth']).toEqual({ + status: 'static', + codeValue: 2, + }); + expect(afterShorthand.props['style.borderWidth']).toEqual({ + status: 'static', + codeValue: 8, + }); +}); + +test('computeSequencePropsStatus should apply border shorthand defaults', () => { + const input = `import {AbsoluteFill} from 'remotion'; + +export const Example = () => { + return ; +}; +`; + const result = computeSequencePropsStatusFromContent({ + fileContents: input, + nodePath: getNodePathFromContent(input, 4), + componentIdentity: null, + keys: ['style.borderWidth', 'style.borderStyle', 'style.borderColor'], + effects: [], + videoConfigValues: null, + }); + + expect(result.props['style.borderWidth']).toEqual({ + status: 'static', + codeValue: 3, + }); + expect(result.props['style.borderStyle']).toEqual({ + status: 'static', + codeValue: 'solid', + }); + expect(result.props['style.borderColor']).toEqual({ + status: 'static', + codeValue: 'currentColor', + }); +}); + test('canUpdateSequenceProps should flag computed props', () => { const filePath = path.join(__dirname, 'snapshots', 'light-leak-computed.tsx'); const result = computeSequencePropsStatus({ diff --git a/packages/studio-server/src/test/css-shorthand-properties.test.ts b/packages/studio-server/src/test/css-shorthand-properties.test.ts new file mode 100644 index 00000000000..a9678022c69 --- /dev/null +++ b/packages/studio-server/src/test/css-shorthand-properties.test.ts @@ -0,0 +1,39 @@ +import {expect, test} from 'bun:test'; +import { + getCssShorthandForLonghand, + getCssShorthandsForUpdates, +} from '../helpers/css-shorthand-properties'; + +test('registers border longhands and side-specific blockers', () => { + const border = getCssShorthandForLonghand({ + parentKey: 'style', + longhand: 'borderWidth', + }); + + expect(border?.shorthand).toBe('border'); + expect(border?.longhands).toEqual([ + 'borderWidth', + 'borderStyle', + 'borderColor', + ]); + + for (const sideProperty of [ + 'borderLeft', + 'borderRightWidth', + 'borderTopStyle', + 'borderBottomColor', + ]) { + expect(border?.isUnsupportedProperty(sideProperty)).toBe(true); + } + + expect(border?.isUnsupportedProperty('borderRadius')).toBe(false); +}); + +test('selects shorthand migrations from dot-notation update keys', () => { + expect( + getCssShorthandsForUpdates(['style.opacity', 'style.borderColor']).map( + (property) => property.shorthand, + ), + ).toEqual(['border']); + expect(getCssShorthandsForUpdates(['style.opacity'])).toEqual([]); +}); diff --git a/packages/studio-server/src/test/discriminated-union-update.test.ts b/packages/studio-server/src/test/discriminated-union-update.test.ts index aa2593e1eb2..29b9e7235c5 100644 --- a/packages/studio-server/src/test/discriminated-union-update.test.ts +++ b/packages/studio-server/src/test/discriminated-union-update.test.ts @@ -52,6 +52,9 @@ test('Should expose absolute-fill variant fields when active', () => { 'style.scale', 'style.rotate', 'style.opacity', + 'style.borderWidth', + 'style.borderStyle', + 'style.borderColor', ]); }); diff --git a/packages/studio-server/src/test/get-all-schema-keys.test.ts b/packages/studio-server/src/test/get-all-schema-keys.test.ts index a3a8a73fe53..f18b41bb3ee 100644 --- a/packages/studio-server/src/test/get-all-schema-keys.test.ts +++ b/packages/studio-server/src/test/get-all-schema-keys.test.ts @@ -19,6 +19,9 @@ test('getAllSchemaKeys returns every key across all enum variants', () => { 'style.rotate', 'style.transformOrigin', 'style.opacity', + 'style.borderWidth', + 'style.borderStyle', + 'style.borderColor', 'premountFor', 'postmountFor', 'styleWhilePremounted', diff --git a/packages/studio-server/src/test/update-sequence-props.test.ts b/packages/studio-server/src/test/update-sequence-props.test.ts index 84f9062732e..cc7719e6f8c 100644 --- a/packages/studio-server/src/test/update-sequence-props.test.ts +++ b/packages/studio-server/src/test/update-sequence-props.test.ts @@ -83,6 +83,34 @@ test('updateSequenceProps should update a number value', async () => { expect(output.split('\n')[8]).toContain('hueShift={30}'); }); +test('updateSequenceProps should migrate border shorthand to longhands', async () => { + const input = `import {AbsoluteFill} from 'remotion'; + +export const Example = () => { + return ( + + ); +}; +`; + const {output, oldValueStrings} = await updateSequenceProps({ + videoConfigValues: null, + input, + nodePath: lineColumnToNodePath(input, 5), + updates: [{key: 'style.borderWidth', value: 8, defaultValue: undefined}], + schema: NoReactInternals.sequenceSchema, + prettierConfigOverride: null, + }); + + expect(output).not.toContain("border: '2px solid"); + expect(output).toContain('borderWidth: 8'); + expect(output).toContain("borderStyle: 'solid'"); + expect(output).toContain("borderColor: 'rgba(10, 20, 30, 0.5)'"); + expect(output).toContain('opacity: 0.5'); + expect(oldValueStrings).toEqual(['2']); +}); + test('updateSequenceProps should update durationInFrames', async () => { const {output, oldValueStrings} = await updateSequenceProps({ videoConfigValues: null, diff --git a/packages/studio-shared/src/schema-field-info.ts b/packages/studio-shared/src/schema-field-info.ts index c9b14bb6c26..28ba7621b32 100644 --- a/packages/studio-shared/src/schema-field-info.ts +++ b/packages/studio-shared/src/schema-field-info.ts @@ -41,7 +41,12 @@ export type AnySchemaFieldInfo = export const SCHEMA_FIELD_ROW_HEIGHT = 22; -export type SchemaFieldGroup = 'source' | 'controls' | 'transforms' | 'text'; +export type SchemaFieldGroup = + | 'source' + | 'controls' + | 'transforms' + | 'border' + | 'text'; export type SchemaFieldGroupInfo = { readonly id: SchemaFieldGroup; @@ -53,6 +58,7 @@ export const SCHEMA_FIELD_GROUPS = [ {id: 'controls', label: 'Controls'}, {id: 'transforms', label: 'Transform'}, {id: 'text', label: 'Text'}, + {id: 'border', label: 'Border'}, ] as const satisfies readonly SchemaFieldGroupInfo[]; const schemaFieldGroupOrder = SCHEMA_FIELD_GROUPS.reduce( @@ -71,6 +77,12 @@ const TRANSFORM_FIELD_KEYS = new Set([ 'style.opacity', ]); +const BORDER_FIELD_KEYS = new Set([ + 'style.borderWidth', + 'style.borderStyle', + 'style.borderColor', +]); + const TEXT_FIELD_KEYS = new Set([ 'children', 'style.color', @@ -92,6 +104,10 @@ export const getSchemaFieldGroup = (key: string): SchemaFieldGroup => { return 'transforms'; } + if (BORDER_FIELD_KEYS.has(key)) { + return 'border'; + } + if (TEXT_FIELD_KEYS.has(key)) { return 'text'; } diff --git a/packages/studio-shared/src/test/keyframe-interpolation-function.test.ts b/packages/studio-shared/src/test/keyframe-interpolation-function.test.ts index ec7080e25e6..3b0973a84eb 100644 --- a/packages/studio-shared/src/test/keyframe-interpolation-function.test.ts +++ b/packages/studio-shared/src/test/keyframe-interpolation-function.test.ts @@ -1,5 +1,6 @@ import {expect, test} from 'bun:test'; import type {InteractivitySchema} from 'remotion'; +import {Interactive} from 'remotion'; import { canEditEasingForInterpolationFunction, getKeyframeInterpolationFunctionForSchemaField, @@ -7,6 +8,20 @@ import { isSchemaFieldKeyframable, } from '../keyframe-interpolation-function'; +test('border longhand fields keyframe width and color, but not style', () => { + const schema = Interactive.borderSchema; + + expect(isSchemaFieldKeyframable({schema, key: 'style.borderWidth'})).toBe( + true, + ); + expect(isSchemaFieldKeyframable({schema, key: 'style.borderColor'})).toBe( + true, + ); + expect(isSchemaFieldKeyframable({schema, key: 'style.borderStyle'})).toBe( + false, + ); +}); + test('known interpolation functions explicitly support easing', () => { expect(canEditEasingForInterpolationFunction('interpolate')).toBe(true); expect(canEditEasingForInterpolationFunction('interpolateColors')).toBe(true); diff --git a/packages/studio-shared/src/test/schema-field-info.test.ts b/packages/studio-shared/src/test/schema-field-info.test.ts index effdbd12cd7..b744f198d62 100644 --- a/packages/studio-shared/src/test/schema-field-info.test.ts +++ b/packages/studio-shared/src/test/schema-field-info.test.ts @@ -199,6 +199,23 @@ test('getFieldsToShow sorts fields by inspector group order', () => { default: 1, hiddenFromList: false, }, + 'style.borderWidth': { + type: 'number', + default: undefined, + hiddenFromList: false, + }, + 'style.borderStyle': { + type: 'enum', + default: 'none', + variants: { + none: {}, + solid: {}, + }, + }, + 'style.borderColor': { + type: 'color', + default: undefined, + }, volume: { type: 'number', default: 1, @@ -236,6 +253,9 @@ test('getFieldsToShow sorts fields by inspector group order', () => { 'style.fontFamily', 'style.letterSpacing', 'style.textAlign', + 'style.borderWidth', + 'style.borderStyle', + 'style.borderColor', ]); expect(fields?.map((field) => field.group)).toEqual([ 'source', @@ -249,6 +269,9 @@ test('getFieldsToShow sorts fields by inspector group order', () => { 'text', 'text', 'text', + 'border', + 'border', + 'border', ]); });