From 66f0366611c1002e39906ae2a68b6f8edf679a2b Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 10 Jun 2026 13:59:28 -0400 Subject: [PATCH 1/2] feat(core): placeholderColor renders a solid rect while a texture loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node with src + color tints the image (color is a vertex attribute multiplied into the texture sample), and a node with an unloaded texture renders nothing — so there was no single-node way to show a background placeholder while an image loads. New per-node placeholderColor prop (default 0 = off): while the texture is not loaded the quad samples the stage's shared 1x1 white default texture tinted by the premultiplied placeholder color — exactly the color-rect path, through the node's own shader, so rounded corners and borders apply. The placeholder also shows while a memory-pressure-freed texture reloads and remains if the texture permanently fails. Cost model: zero extra nodes/quads/overdraw; placeholder state is recomputed only from texture lifecycle events (nothing added to the per-translation scroll path); shader uniform value-key caching is untouched (uniforms remain a function of resolvedProps + w/h); all placeholder quads share one texture so they batch at least as well as loaded images. Implemented for both WebGL and Canvas2D. Also fixes animation color detection to match camelCase *Color props so animating placeholderColor interpolates in color space. Co-Authored-By: Claude Fable 5 --- examples/tests/texture-placeholder-color.ts | 129 +++++++++++ src/core/CoreNode.test.ts | 200 ++++++++++++++++++ src/core/CoreNode.ts | 141 ++++++++++-- src/core/CoreTextNode.test.ts | 1 + src/core/Stage.ts | 2 + src/core/animations/CoreAnimation.ts | 4 +- src/core/renderers/canvas/CanvasRenderer.ts | 8 +- src/core/renderers/webgl/WebGlRenderer.ts | 7 +- .../texture-placeholder-color-1.png | Bin 0 -> 23733 bytes 9 files changed, 466 insertions(+), 26 deletions(-) create mode 100644 examples/tests/texture-placeholder-color.ts create mode 100644 visual-regression/certified-snapshots/chromium-ci/texture-placeholder-color-1.png diff --git a/examples/tests/texture-placeholder-color.ts b/examples/tests/texture-placeholder-color.ts new file mode 100644 index 0000000..f3c5553 --- /dev/null +++ b/examples/tests/texture-placeholder-color.ts @@ -0,0 +1,129 @@ +import type { INode } from '@lightningjs/renderer'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +import rockoPng from '../assets/rocko.png'; + +/** + * Visual test for `placeholderColor`: a node with a texture renders a solid + * color rect (through its shader, so rounded corners apply) until the texture + * loads, instead of rendering nothing. + * + * Deterministic states captured in the snapshot: + * 1. Rounded placeholder for a permanently failed src (placeholder remains). + * 2. RoundedWithBorder placeholder for a permanently failed src. + * 3. A loaded image with placeholderColor set — the image shows untinted, + * proving the placeholder does not leak into the loaded state. + * 4. Control: failed src without placeholderColor renders nothing. + */ + +const MISSING_SRC = '/does-not-exist-placeholder-test.png'; + +function waitForEvent( + node: INode, + event: 'loaded' | 'failed', + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), timeoutMs); + node.once(event, () => { + clearTimeout(timeout); + resolve(true); + }); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function automation(settings: ExampleSettings) { + await test(settings); + // The scene settled inside test() (loaded/failed events already awaited), + // so the 'idle' transition may have already fired — don't wait for it. + // Force a final frame and let it draw instead. + settings.renderer.rerender(); + await delay(100); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + renderer.createTextNode({ + fontFamily: 'Ubuntu', + text: 'placeholderColor', + fontSize: 30, + color: 0xffffffff, + x: 20, + y: 20, + parent: testRoot, + }); + + // Fail fast and permanently: maxRetryCount 0 = one attempt, no retries. + const missingTexture = renderer.createTexture('ImageTexture', { + src: MISSING_SRC, + maxRetryCount: 0, + }); + + // 1. Placeholder stays visible for a permanently failed texture (rounded). + const failedRounded = renderer.createNode({ + x: 20, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderColor: 0x336699ff, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 2. Same with a border shader. + const failedBordered = renderer.createNode({ + x: 250, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderColor: 0x993311ff, + shader: renderer.createShader('RoundedWithBorder', { + radius: [20], + 'border-w': 8, + }), + parent: testRoot, + }); + + // 3. A successfully loaded image with a placeholder configured must show + // the image, untinted. + const loadedImage = renderer.createNode({ + x: 480, + y: 80, + w: 181, + h: 218, + src: rockoPng, + placeholderColor: 0x336699ff, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 4. Control: a failed texture without a placeholder renders nothing. + renderer.createNode({ + x: 710, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + parent: testRoot, + }); + + const settled = await Promise.all([ + waitForEvent(failedRounded, 'failed', 10000), + waitForEvent(failedBordered, 'failed', 10000), + waitForEvent(loadedImage, 'loaded', 10000), + ]); + + if (settled[0] === false || settled[1] === false || settled[2] === false) { + console.error('[texture-placeholder-color] scene did not settle', settled); + return false; + } + + console.log('[texture-placeholder-color] scene settled'); + return true; +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index bbcc44f..7411be1 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -7,6 +7,8 @@ import { type TextureOptions } from './CoreTextureManager.js'; import { createBound } from './lib/utils.js'; import { ImageTexture } from './textures/ImageTexture.js'; import { Matrix3d } from './lib/Matrix3d.js'; +import { EventEmitter } from '../common/EventEmitter.js'; +import { premultiplyColorABGR } from '../utils.js'; describe('set color()', () => { const defaultProps = (overrides?: Partial): CoreNodeProps => ({ @@ -23,6 +25,7 @@ describe('set color()', () => { colorTl: 0, colorTop: 0, colorTr: 0, + placeholderColor: 0, h: 0, mount: 0, mountX: 0, @@ -1263,4 +1266,201 @@ describe('set color()', () => { expect(shader.update).toHaveBeenCalledTimes(2); }); }); + + describe('placeholderColor', () => { + // A texture fake on a real EventEmitter so CoreNode's loadTextureTask + // subscribes for real and we can drive the loaded/freed/failed handler + // chain by emitting, like the engine does. + function emittingTexture(state: string): ImageTexture & { + emit: (event: string, data?: unknown) => void; + } { + return Object.assign(new EventEmitter(), { + state, + preventCleanup: false, + retryCount: 0, + maxRetryCount: 1, + dimensions: { w: 100, h: 100 }, + setRenderableOwner: vi.fn(), + }) as unknown as ImageTexture & { + emit: (event: string, data?: unknown) => void; + }; + } + + function visibleNode(): CoreNode { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.alpha = 1; + node.x = 0; + node.y = 0; + node.w = 100; + node.h = 100; + return node; + } + + // Flush the queueMicrotask(loadTextureTask) so listeners attach. + const flushMicrotasks = () => Promise.resolve(); + + it('renders the placeholder while the texture is loading', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + node.texture = emittingTexture('initial'); + + node.update(0, clippingRect); + + expect(node.placeholderActive).toBe(true); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + + const expected = premultiplyColorABGR(0x336699ff, 1); + expect(node.premultipliedColorTl).toBe(expected); + expect(node.premultipliedColorTr).toBe(expected); + expect(node.premultipliedColorBl).toBe(expected); + expect(node.premultipliedColorBr).toBe(expected); + }); + + it('is inactive without a placeholderColor (loading renders nothing)', () => { + const node = visibleNode(); + node.texture = emittingTexture('initial'); + + node.update(0, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(false); + }); + + it('is inactive without a texture (color-only nodes are unaffected)', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + + node.update(0, clippingRect); + + expect(node.placeholderActive).toBe(false); + }); + + it('switches to the texture and regular colors once loaded', async () => { + const node = visibleNode(); + node.color = 0xffffffff; + node.placeholderColor = 0x336699ff; + const texture = emittingTexture('initial'); + node.texture = texture; + node.update(0, clippingRect); + expect(node.renderTexture).toBe(stage.defaultTexture); + + await flushMicrotasks(); + (texture as { state: string }).state = 'loaded'; + texture.emit('loaded', { w: 100, h: 100 }); + node.isQuadDirty = false; + node.update(1, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(texture); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0xffffffff, 1), + ); + // The color switch must reach the GPU quad buffer + expect(node.isQuadDirty).toBe(true); + }); + + it('shows the placeholder again while a freed texture reloads', async () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + const texture = emittingTexture('initial'); + node.texture = texture; + node.update(0, clippingRect); + + await flushMicrotasks(); + (texture as { state: string }).state = 'loaded'; + texture.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + expect(node.placeholderActive).toBe(false); + + (texture as { state: string }).state = 'freed'; + texture.emit('freed'); + node.update(2, clippingRect); + + expect(node.placeholderActive).toBe(true); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0x336699ff, 1), + ); + }); + + it('keeps the placeholder when the texture permanently fails', async () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + const texture = emittingTexture('initial'); + node.texture = texture; + node.update(0, clippingRect); + + await flushMicrotasks(); + (texture as { state: string }).state = 'failed'; + (texture as { retryCount: number }).retryCount = 2; // > maxRetryCount (1) + texture.emit('failed', new Error('404')); + node.update(1, clippingRect); + + expect(node.placeholderActive).toBe(true); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + }); + + it('a permanently failed texture without a placeholder stays non-renderable', async () => { + const node = visibleNode(); + const texture = emittingTexture('initial'); + node.texture = texture; + node.update(0, clippingRect); + + await flushMicrotasks(); + (texture as { state: string }).state = 'failed'; + (texture as { retryCount: number }).retryCount = 2; + texture.emit('failed', new Error('404')); + node.update(1, clippingRect); + + expect(node.isRenderable).toBe(false); + }); + + it('deactivates when placeholderColor is cleared while loading', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + expect(node.isRenderable).toBe(true); + + node.placeholderColor = 0; + node.update(1, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(false); + }); + + it('updates the shown color when placeholderColor changes while active', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + + node.placeholderColor = 0x993311ff; + node.update(1, clippingRect); + + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0x993311ff, 1), + ); + }); + + it('does not activate when the assigned texture is already loaded', () => { + const node = visibleNode(); + node.placeholderColor = 0x336699ff; + const texture = emittingTexture('loaded'); + node.texture = texture; + + node.update(0, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.renderTexture).toBe(texture); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 6f1386c..321076c 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -392,6 +392,23 @@ export interface CoreNodeProps { * rendering. */ colorBl: number; + /** + * Placeholder color shown while the Node's texture is not yet loaded. + * + * @remarks + * When set to a non-zero color and the Node has a texture (e.g. {@link src}), + * the Node renders a solid rectangle of this color until the texture + * finishes loading, instead of rendering nothing. The placeholder renders + * through the Node's shader, so rounded corners and borders apply to it. + * It also shows again if the texture is freed under memory pressure (until + * the reload completes) and remains if the texture permanently fails. + * + * The color value is a number in the format 0xRRGGBBAA. Set to `0` to + * disable (default). Has no effect on Nodes without a texture. + * + * @default `0` + */ + placeholderColor: number; /** * The Node's parent Node. * @@ -793,6 +810,15 @@ export class CoreNode extends EventEmitter { private hasColorProps = false; public textureLoaded = false; + /** + * True while this node should render its `placeholderColor` instead of its + * texture: `placeholderColor` is non-zero, a texture is set, and that + * texture is not loaded. Read by the renderers' quad path to substitute the + * stage's default (1x1 white) texture. Maintained by + * {@link updatePlaceholderActive} — never written elsewhere. + */ + public placeholderActive = false; + public updateType = UpdateType.All; public childUpdateType = UpdateType.None; @@ -953,6 +979,31 @@ export class CoreNode extends EventEmitter { } //#region Textures + /** + * Recompute {@link placeholderActive} after any of its inputs changed + * (placeholderColor, texture, textureLoaded). + * + * @remarks + * On a toggle this raises `PremultipliedColors` (the quad's vertex colors + * switch between the placeholder color and the regular color props — this + * also marks the quad dirty) and `IsRenderable` (a loading texture with a + * placeholder is renderable). Both are processed in the same frame's update + * pass, before quads are submitted. + */ + private updatePlaceholderActive(): void { + const active = + this.props.placeholderColor !== 0 && + this.props.texture !== null && + this.textureLoaded === false; + + if (active !== this.placeholderActive) { + this.placeholderActive = active; + this.setUpdateType( + UpdateType.PremultipliedColors | UpdateType.IsRenderable, + ); + } + } + loadTexture(): void { if (this.props.texture === null) { return; @@ -1023,6 +1074,7 @@ export class CoreNode extends EventEmitter { } this.textureLoaded = true; + this.updatePlaceholderActive(); this.setUpdateType(UpdateType.IsRenderable); // Texture was loaded. In case the RAF loop has already stopped, we request @@ -1057,9 +1109,12 @@ export class CoreNode extends EventEmitter { private onTextureFailed: TextureFailedEventHandler = (_, error) => { // immediately set isRenderable to false, so that we handle the error - // without waiting for the next frame loop + // without waiting for the next frame loop. With a placeholder set, the + // same frame's update pass recomputes this to true and renders the + // placeholder instead. this.textureLoaded = false; this.isRenderable = false; + this.updatePlaceholderActive(); this.updateTextureOwnership(false); this.setUpdateType(UpdateType.IsRenderable); @@ -1081,9 +1136,12 @@ export class CoreNode extends EventEmitter { private onTextureFreed: TextureFreedEventHandler = () => { // immediately set isRenderable to false, so that we handle the error - // without waiting for the next frame loop + // without waiting for the next frame loop. With a placeholder set, the + // same frame's update pass recomputes this to true and renders the + // placeholder while the texture reloads. this.textureLoaded = false; this.isRenderable = false; + this.updatePlaceholderActive(); this.updateTextureOwnership(false); this.setUpdateType(UpdateType.IsRenderable); @@ -1426,27 +1484,39 @@ export class CoreNode extends EventEmitter { if (updateType & UpdateType.PremultipliedColors) { const alpha = this.worldAlpha; - const tl = props.colorTl; - const tr = props.colorTr; - const bl = props.colorBl; - const br = props.colorBr; - - // Fast equality check (covers all 4 corners) - const same = tl === tr && tl === bl && tl === br; - - const merged = premultiplyColorABGR(tl, alpha); - - this.premultipliedColorTl = merged; - - if (same === true) { - this.premultipliedColorTr = + if (this.placeholderActive === true) { + // Placeholder rendering: all four corners take the placeholder color. + // The quad samples the stage's default 1x1 white texture, so this is + // exactly the color-rect path. + const merged = premultiplyColorABGR(props.placeholderColor, alpha); + this.premultipliedColorTl = + this.premultipliedColorTr = this.premultipliedColorBl = this.premultipliedColorBr = merged; } else { - this.premultipliedColorTr = premultiplyColorABGR(tr, alpha); - this.premultipliedColorBl = premultiplyColorABGR(bl, alpha); - this.premultipliedColorBr = premultiplyColorABGR(br, alpha); + const tl = props.colorTl; + const tr = props.colorTr; + const bl = props.colorBl; + const br = props.colorBr; + + // Fast equality check (covers all 4 corners) + const same = tl === tr && tl === bl && tl === br; + + const merged = premultiplyColorABGR(tl, alpha); + + this.premultipliedColorTl = merged; + + if (same === true) { + this.premultipliedColorTr = + this.premultipliedColorBl = + this.premultipliedColorBr = + merged; + } else { + this.premultipliedColorTr = premultiplyColorABGR(tr, alpha); + this.premultipliedColorBl = premultiplyColorABGR(bl, alpha); + this.premultipliedColorBr = premultiplyColorABGR(br, alpha); + } } } @@ -1760,15 +1830,18 @@ export class CoreNode extends EventEmitter { // preemptive check for failed textures this will mark the current node as non-renderable // and will prevent further checks until the texture is reloaded or retry is reset on the texture if (this.texture.retryCount > this.texture.maxRetryCount) { - // texture has failed to load, we cannot render + // texture has failed to load, we cannot render the texture itself — + // but a placeholder color still renders in its place this.updateTextureOwnership(false); - this.setRenderable(false); + this.setRenderable(this.placeholderActive); return; } needsTextureOwnership = true; - // Use cached boolean instead of string comparison - newIsRenderable = this.textureLoaded; + // Use cached boolean instead of string comparison; a placeholder + // renders while the texture is loading + newIsRenderable = + this.textureLoaded === true || this.placeholderActive === true; } else if ( // check shader (this.props.shader !== this.stage.renderer.getDefaultShaderNode() || @@ -2055,6 +2128,9 @@ export class CoreNode extends EventEmitter { } get renderTexture(): Texture | null { + if (this.placeholderActive === true) { + return this.stage.defaultTexture; + } return this.props.texture || this.stage.defaultTexture; } @@ -2524,6 +2600,24 @@ export class CoreNode extends EventEmitter { this.setUpdateType(UpdateType.PremultipliedColors); } + get placeholderColor(): number { + return this.props.placeholderColor; + } + + set placeholderColor(value: number) { + const p = this.props; + if (p.placeholderColor === value) return; + + p.placeholderColor = value; + this.updatePlaceholderActive(); + + // If the placeholder is (still) showing, the new color must reach the + // quad buffer even though the active state did not toggle. + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + } + get colorTop(): number { return this.props.colorTop; } @@ -2944,6 +3038,7 @@ export class CoreNode extends EventEmitter { this.textureCoords = undefined; this.props.texture = value; this.textureLoaded = value !== null && value.state === 'loaded'; + this.updatePlaceholderActive(); if (value !== null) { if (this.autosizer !== null) { diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index 8031a6b..c1edaf6 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -28,6 +28,7 @@ const defaultProps = ( colorTl: 0xffffffff, colorTop: 0xffffffff, colorTr: 0xffffffff, + placeholderColor: 0, h: 0, mount: 0, mountX: 0, diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 946c3fc..39941e2 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -382,6 +382,7 @@ export class Stage { colorRight: 0x00000000, colorTl: 0x00000000, colorTr: 0x00000000, + placeholderColor: 0x00000000, colorBl: 0x00000000, colorBr: 0x00000000, zIndex: 0, @@ -1043,6 +1044,7 @@ export class Stage { colorTr, colorBl, colorBr, + placeholderColor: props.placeholderColor ?? 0, zIndex: props.zIndex ?? 0, parent: props.parent ?? null, texture: props.texture ?? null, diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index 197e3a7..095bc7c 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -36,7 +36,9 @@ export function createAnimation( target: props[ key as keyof Omit ] as number, - isColor: key.indexOf('color') !== -1, + // Matches 'color', 'colorTl', ... and camelCase props like + // 'placeholderColor' so they interpolate in color space. + isColor: key.indexOf('color') !== -1 || key.indexOf('Color') !== -1, }; } else if (key === 'shaderProps' && node.shader !== null) { if (!shaderPropValues) shaderPropValues = {}; diff --git a/src/core/renderers/canvas/CanvasRenderer.ts b/src/core/renderers/canvas/CanvasRenderer.ts index b13b436..c995611 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -52,7 +52,13 @@ export class CanvasRenderer extends CoreRenderer { const ctx = this.context; const { tx, ty, ta, tb, tc, td } = node.globalTransform!; const clippingRect = node.clippingRect; - let texture = (node.props.texture || this.stage.defaultTexture) as Texture; + // While a placeholder is showing, render the color-rect path (the default + // ColorTexture) tinted by the node's premultiplied placeholder color. + let texture = ( + node.placeholderActive === true + ? this.stage.defaultTexture + : node.props.texture || this.stage.defaultTexture + ) as Texture; // The Canvas2D renderer only supports image textures, no textures are used for color blocks if (texture !== null) { const textureType = texture.type; diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index cf21100..8fbaccc 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -471,7 +471,12 @@ export class WebGlRenderer extends CoreRenderer { } const props = node.props; - let tx = props.texture || this.stage.defaultTexture!; + // While a placeholder is showing, the quad samples the shared 1x1 white + // texture tinted by the node's premultiplied placeholder color. + let tx = + node.placeholderActive === true + ? this.stage.defaultTexture! + : props.texture || this.stage.defaultTexture!; if (tx.type === TextureType.subTexture) { tx = (tx as SubTexture).parentTexture; diff --git a/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-color-1.png b/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-color-1.png new file mode 100644 index 0000000000000000000000000000000000000000..ce49ba75a2a0ec36b841385dae5a6649b362d189 GIT binary patch literal 23733 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 Date: Wed, 10 Jun 2026 14:06:21 -0400 Subject: [PATCH 2/2] revert: drop CoreAnimation camelCase color detection placeholderColor is not animatable, so the broader isColor match is unnecessary. Co-Authored-By: Claude Fable 5 --- src/core/animations/CoreAnimation.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/animations/CoreAnimation.ts b/src/core/animations/CoreAnimation.ts index 095bc7c..197e3a7 100644 --- a/src/core/animations/CoreAnimation.ts +++ b/src/core/animations/CoreAnimation.ts @@ -36,9 +36,7 @@ export function createAnimation( target: props[ key as keyof Omit ] as number, - // Matches 'color', 'colorTl', ... and camelCase props like - // 'placeholderColor' so they interpolate in color space. - isColor: key.indexOf('color') !== -1 || key.indexOf('Color') !== -1, + isColor: key.indexOf('color') !== -1, }; } else if (key === 'shaderProps' && node.shader !== null) { if (!shaderPropValues) shaderPropValues = {};