feat(editor and runtime): per-channel (R/G/B/A) passthrough color pro… (#13324) 20db61d6e7
feat(editor and runtime): per-channel (R/G/B/A) passthrough color properties

Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head
index 5ee99bb..0e228a2 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-e9f24d0296d8f2513b0ac1287b9c5fb5f1314f7f
+20db61d6e7ec27f06f7d485ab8ad293b540508a3
diff --git a/dev/core_generator/lib/src/definition.dart b/dev/core_generator/lib/src/definition.dart
index 597967b..66acbac 100644
--- a/dev/core_generator/lib/src/definition.dart
+++ b/dev/core_generator/lib/src/definition.dart
@@ -47,6 +47,10 @@
   Definition? _rawExtensionOf;
   Key? _key;
   bool _isAbstract = false;
+  bool _isMixin = false;
+  bool get isMixin => _isMixin;
+  final List<Definition> _mixinsOf = [];
+  List<Definition> get mixinsOf => _mixinsOf;
   bool _editorOnly = false;
   bool _forRuntime = true;
   bool get forRuntime => _forRuntime;
@@ -103,6 +107,40 @@
     if (abstractValue is bool) {
       _isAbstract = abstractValue;
     }
+    dynamic isMixinValue = data['isMixin'];
+    if (isMixinValue is bool) {
+      _isMixin = isMixinValue;
+    }
+    // Parse mixins. In the C++ runtime a mixin is emitted as a non-Core
+    // interface base (multiple inheritance) plus per-consumer dispatch in
+    // core_registry (via a static from(Core*) resolver). We record the mixin
+    // relationship for codegen/registry dispatch, but do not fold the mixin's
+    // properties into the consumer's own property list.
+    void addMixin(String name) {
+      // Editor-only mixins (e.g. publishable, taggable) are not synced into the
+      // runtime def tree. Skip any mixin whose def is absent here — only
+      // runtime-capable mixins (e.g. color_channels) participate in C++.
+      if (!File(defsPath + name).existsSync()) {
+        return;
+      }
+      final mixinDef = Definition.make(name);
+      if (mixinDef != null) {
+        _mixinsOf.add(mixinDef);
+      }
+    }
+
+    dynamic mixinFilename = data['mixin'];
+    if (mixinFilename is String) {
+      addMixin(mixinFilename);
+    }
+    dynamic mixinFilenames = data['mixins'];
+    if (mixinFilenames is List) {
+      for (final name in mixinFilenames) {
+        if (name is String) {
+          addMixin(name);
+        }
+      }
+    }
     dynamic editorOnlyValue = data['editorOnly'];
     if (editorOnlyValue is bool) {
       _editorOnly = editorOnlyValue;
@@ -168,6 +206,23 @@
         }
       }
       if (target == null) {
+        // A mixin can passthrough a mask provided by the host class that
+        // includes it (e.g. `colorValue` on SolidColor/GradientStop). The
+        // mask is not a same-def sibling; core_registry drives it per
+        // consuming type. Bit/width validation still applies.
+        if (_isMixin) {
+          p.bitmaskTargetIsHostProvided = true;
+          final bit = p.passthroughBit!;
+          final width = p.passthroughBitWidthOrDefault;
+          if (bit < 0 || width < 1 || bit + width > 32) {
+            color(
+              '${p.name}: passthroughBit/passthroughBitWidth must fit in 0..32 '
+              '(bit $bit, width $width).',
+              front: Styles.RED,
+            );
+          }
+          continue;
+        }
         color(
           '${p.name}: passthroughForBitmask "${p.passthroughForBitmask}" '
           'not found.',
@@ -175,9 +230,10 @@
         );
         continue;
       }
-      if (target.type.name != 'uint') {
+      // Color masks are uint32 under the hood, so they are valid targets too.
+      if (target.type.name != 'uint' && target.type.name != 'Color') {
         color(
-          '${p.name}: passthroughForBitmask target must be uint.',
+          '${p.name}: passthroughForBitmask target must be uint or Color.',
           front: Styles.RED,
         );
         continue;
@@ -244,11 +300,113 @@
   String get concreteCodeFilename => 'rive/${stripExtension(_filename)}.hpp';
   String get localCppCodeFilename => '${stripExtension(_filename)}_base.cpp';
 
+  /// Runtime types that include this mixin (scanned once all defs are loaded).
+  List<Definition> get _mixinConsumers => definitions.values
+      .where((d) => d.forRuntime && d._mixinsOf.contains(this))
+      .toList();
+
+  /// A runtime mixin is emitted as a non-Core interface class (like
+  /// [ListConstraint]): it declares the host mask(s) as pure virtuals — which
+  /// the consuming type's own generated accessor satisfies — and provides the
+  /// shared channel accessor methods + key constants. A static [from] resolves
+  /// a Core object to the interface (or nullptr). Consuming types inherit it as
+  /// a second base, and core_registry dispatches the shared keys through
+  /// [from].
+  Future<void> _generateMixinInterfaceHeader() async {
+    // Distinct host-provided masks required by this mixin's passthroughs.
+    final hostMasks = <String>{};
+    for (final property in properties) {
+      if (property.isBitmaskPassthrough &&
+          property.bitmaskTargetIsHostProvided) {
+        hostMasks.add(property.passthroughForBitmask!);
+      }
+    }
+
+    StringBuffer code = StringBuffer();
+    code.writeln('#include <cstdint>');
+    code.writeln('namespace rive {');
+    code.writeln('class Core;');
+    code.writeln('class ${_name}Base {');
+    code.writeln('public:');
+    code.writeln('static ${_name}Base* from(Core* object);');
+    // The host provides the packed mask; its own accessor overrides these.
+    for (final mask in hostMasks) {
+      code.writeln('virtual int $mask() const = 0;');
+      code.writeln('virtual void $mask(int value) = 0;');
+    }
+    for (final property in properties) {
+      code.writeln('static const uint16_t ${property.name}PropertyKey = '
+          '${property.key!.intValue};');
+      for (final altKey in property.key!.alternates) {
+        code.writeln('static const uint16_t ${altKey.stringValue}PropertyKey = '
+            '${altKey.intValue};');
+      }
+      if (property.isBitmaskPassthrough &&
+          property.bitmaskTargetIsHostProvided &&
+          property.type.name == 'uint') {
+        final mask = property.passthroughForBitmask!;
+        final bit = property.passthroughBit!;
+        final width = property.passthroughBitWidthOrDefault;
+        final fieldMask = ((1 << width) - 1) << bit;
+        final valueMask = (1 << width) - 1;
+        code.writeln('static const uint32_t ${property.name}BitOffset = $bit;');
+        code.writeln('static const uint32_t ${property.name}FieldMask = '
+            '${fieldMask}u;');
+        code.writeln('uint32_t ${property.name}() const { return '
+            '(static_cast<uint32_t>($mask()) >> $bit) & ${valueMask}u; }');
+        code.writeln('void ${property.name}(uint32_t value) {');
+        // Clamp to the field's range so a channel saturates instead of
+        // wrapping into the neighbouring byte.
+        code.writeln('if (value > ${valueMask}u) { value = ${valueMask}u; }');
+        code.writeln('const int _cur = $mask();');
+        code.writeln('const int _fieldMask = static_cast<int>(${fieldMask}u);');
+        code.writeln('const int _next = static_cast<int>('
+            '(_cur & ~_fieldMask) | ((value << $bit) & _fieldMask));');
+        code.writeln('if (_cur != _next) { $mask(_next); }');
+        code.writeln('}');
+      }
+    }
+    code.writeln('};');
+    code.writeln('}');
+
+    var file = File('$generatedHppPath$localCodeFilename');
+    file.createSync(recursive: true);
+    var formattedCode =
+        await _formatter.formatAndGuard('${_name}Base', code.toString());
+    file.writeAsStringSync(formattedCode, flush: true);
+
+    // Emit the from() implementation switching over consuming types.
+    final consumers = _mixinConsumers;
+    StringBuffer cpp = StringBuffer();
+    cpp.writeln('#include "rive/generated/$localCodeFilename"');
+    cpp.writeln('#include "rive/core.hpp"');
+    for (final consumer in consumers) {
+      cpp.writeln('#include "${consumer.concreteCodeFilename}"');
+    }
+    cpp.writeln('using namespace rive;');
+    cpp.writeln('${_name}Base* ${_name}Base::from(Core* object) {');
+    cpp.writeln('switch (object->coreType()) {');
+    for (final consumer in consumers) {
+      cpp.writeln('case ${consumer.name}Base::typeKey:');
+      cpp.writeln('return object->as<${consumer.name}Base>();');
+    }
+    cpp.writeln('} return nullptr; }');
+
+    var cppFile = File('$generatedCppPath$localCppCodeFilename');
+    cppFile.createSync(recursive: true);
+    var formattedCpp = await _formatter.format(cpp.toString());
+    cppFile.writeAsStringSync(formattedCpp, flush: true);
+  }
+
   /// Generates cpp header code based on the Definition
   Future<void> generateCode() async {
     if (!_forRuntime) {
       return;
     }
+    if (_isMixin) {
+      await _generateMixinInterfaceHeader();
+      return;
+    }
     bool defineContextExtension = _extensionOf?._name == null;
     StringBuffer code = StringBuffer();
 
@@ -266,6 +424,10 @@
           property.type.snakeRuntimeCoreName +
           '.hpp');
     }
+    // Runtime mixins are inherited as (non-Core) second bases.
+    for (final mixin in _mixinsOf) {
+      includes.add('rive/generated/${mixin.localCodeFilename}');
+    }
 
     var sortedIncludes = includes.toList()..sort();
     for (final include in sortedIncludes) {
@@ -280,7 +442,9 @@
 
     code.writeln('namespace rive {');
     var superTypeName = defineContextExtension ? 'Core' : _extensionOf?._name;
-    code.writeln('class ${_name}Base : public $superTypeName {');
+    final mixinBases =
+        _mixinsOf.map((mixin) => ', public ${mixin.name}Base').join();
+    code.writeln('class ${_name}Base : public $superTypeName$mixinBases {');
 
     code.writeln('protected:');
     code.writeln('typedef $superTypeName Super;');
@@ -385,6 +549,11 @@
         if (property.isBitmaskPassthrough) {
           continue;
         }
+        // A stored mask property (e.g. colorValue) that satisfies a mixin's
+        // host-provided pure virtual must be marked `override`.
+        final overridesMixinMask = _mixinsOf.any((m) => m.properties.any((mp) =>
+            mp.bitmaskTargetIsHostProvided &&
+            mp.passthroughForBitmask == property.name));
         if (!property.getExportType().storesData) {
           code.writeln((property.isSetOverride ? '' : 'virtual ') +
               'void ${property.name}' +
@@ -407,8 +576,7 @@
               (property.isSetOverride ? 'override' : '') +
               '= 0;');
         } else if (property.isPassthrough) {
-          code.writeln(
-              'virtual void set${property.capitalizedName}('
+          code.writeln('virtual void set${property.capitalizedName}('
               '${property.type.cppGetterName} value) = 0;');
           code.writeln(
               'virtual ${property.type.cppGetterName} ${property.name}() '
@@ -428,12 +596,16 @@
                   ? 'virtual'
                   : 'inline') +
               ' ${property.type.cppGetterName} ${property.name}() const ' +
-              (property.isGetOverride ? 'override' : '') +
+              ((property.isGetOverride || overridesMixinMask)
+                  ? 'override'
+                  : '') +
               '{ return m_${property.capitalizedName}; }');
           if (!property.isPureVirtual) {
             code.writeln(
                 'void ${property.name}(${property.type.cppName} value) ' +
-                    (property.isSetOverride ? 'override' : '') +
+                    ((property.isSetOverride || overridesMixinMask)
+                        ? 'override'
+                        : '') +
                     '{'
                         'if(m_${property.capitalizedName} == value)'
                         '{return;}'
@@ -660,12 +832,18 @@
     var runtimeDefinitions =
         definitions.values.where((definition) => definition.forRuntime);
     for (final definition in runtimeDefinitions) {
-      includes.add(definition.concreteCodeFilename);
+      // Mixins have no concrete class; include their (constants-only) base
+      // header directly so core_registry can reference the shared key
+      // constants.
+      includes.add(definition._isMixin
+          ? 'rive/generated/${definition.localCodeFilename}'
+          : definition.concreteCodeFilename);
     }
     var includeList = includes.toList()..sort();
     for (final include in includeList) {
       ctxCode.writeln('#include "$include"');
     }
+
     ctxCode.writeln('namespace rive {class CoreRegistry {'
         'public:');
     ctxCode.writeln('static Core* makeCoreInstance(int typeKey) {'
@@ -706,7 +884,22 @@
           if (property.isWithRiveToolsOnly) {
             addPreprocessorStart(ctxCode, withRiveToolsPreprocessor);
           }
-          if (property.isBitmaskPassthrough) {
+          if (property.isBitmaskPassthrough &&
+              property.bitmaskTargetIsHostProvided) {
+            // Shared channel defined in a mixin. Resolve the interface via
+            // from() and let it do the masked write on the host's mask.
+            final iface = '${property.definition.name}Base';
+            ctxCode.writeln('case $iface::${property.name}PropertyKey:');
+            for (final altKey in property.key!.alternates) {
+              ctxCode.writeln('case $iface'
+                  '::${altKey.stringValue}PropertyKey:');
+            }
+            ctxCode.writeln('{');
+            ctxCode.writeln('if (auto* _c = $iface::from(object)) { '
+                '_c->${property.name}(value); }');
+            ctxCode.writeln('break;');
+            ctxCode.writeln('}');
+          } else if (property.isBitmaskPassthrough) {
             final mask = property.bitmaskTargetProperty!.name;
             final bit = property.passthroughBit!;
             final maskType = property.bitmaskTargetProperty!.type.cppName;
@@ -720,29 +913,23 @@
               }
             }
             ctxCode.writeln('{');
-            ctxCode.writeln(
-                'auto* _o = object->as<${defName}Base>();');
+            ctxCode.writeln('auto* _o = object->as<${defName}Base>();');
             ctxCode.writeln('if (_o) {');
-            ctxCode.writeln(
-                'const $maskType _cur = _o->$mask();');
+            ctxCode.writeln('const $maskType _cur = _o->$mask();');
             if (property.type.name == 'uint') {
               final width = property.passthroughBitWidthOrDefault;
               final fieldMask = ((1 << width) - 1) << bit;
-              ctxCode.writeln(
-                  'const $maskType _fieldMask = '
+              ctxCode.writeln('const $maskType _fieldMask = '
                   'static_cast<$maskType>(${fieldMask}u);');
-              ctxCode.writeln(
-                  'const $maskType _next = static_cast<$maskType>(('
+              ctxCode.writeln('const $maskType _next = static_cast<$maskType>(('
                   '_cur & ~_fieldMask) | ((value << $bit) & _fieldMask));');
             } else {
               ctxCode.writeln(
                   'const $maskType _bm = static_cast<$maskType>(1u << $bit);');
-              ctxCode.writeln(
-                  'const $maskType _next = static_cast<$maskType>(('
+              ctxCode.writeln('const $maskType _next = static_cast<$maskType>(('
                   '_cur & ~_bm) | (value ? _bm : static_cast<$maskType>(0)));');
             }
-            ctxCode.writeln(
-                'if (_cur != _next) { _o->$mask(_next); }');
+            ctxCode.writeln('if (_cur != _next) { _o->$mask(_next); }');
             ctxCode.writeln('}');
             ctxCode.writeln('break;');
             ctxCode.writeln('}');
@@ -792,7 +979,13 @@
             ctxCode.writeln('case ${property.definition.name}Base'
                 '::${altKey.stringValue}PropertyKey:');
           }
-          if (property.isBitmaskPassthrough) {
+          if (property.isBitmaskPassthrough &&
+              property.bitmaskTargetIsHostProvided) {
+            final iface = '${property.definition.name}Base';
+            ctxCode.writeln('if (auto* _c = $iface::from(object)) { '
+                'return _c->${property.name}(); }');
+            ctxCode.writeln('return 0u;');
+          } else if (property.isBitmaskPassthrough) {
             final mask = property.bitmaskTargetProperty!.name;
             final bit = property.passthroughBit!;
             final width = property.passthroughBitWidthOrDefault;
@@ -825,9 +1018,9 @@
       var properties = usedFieldTypes[fieldType];
       if (properties != null) {
         for (final property in properties) {
-          if (property.isBitmaskPassthrough) {
-            continue;
-          }
+          // Bitmask passthroughs are not serialized on their own, but they DO
+          // have a runtime field type (their value type, e.g. uint) that data
+          // binding needs to resolve the target value — so include them here.
           if (property.isWithRiveToolsOnly) {
             addPreprocessorStart(ctxCode, withRiveToolsPreprocessor);
           }
@@ -887,8 +1080,15 @@
             ctxCode.writeln('case ${property.definition.name}Base'
                 '::${altKey.stringValue}PropertyKey:');
           }
-          ctxCode
-              .writeln('return object->is<${property.definition.name}Base>();');
+          if (property.bitmaskTargetIsHostProvided) {
+            // Shared mixin key: supported by any consuming type.
+            ctxCode
+                .writeln('return ${property.definition.name}Base::from(object) '
+                    '!= nullptr;');
+          } else {
+            ctxCode.writeln(
+                'return object->is<${property.definition.name}Base>();');
+          }
           if (property.isWithRiveToolsOnly) {
             addPreprocessorEnd(ctxCode);
           }
diff --git a/dev/core_generator/lib/src/property.dart b/dev/core_generator/lib/src/property.dart
index 615f126..a95a5c5 100644
--- a/dev/core_generator/lib/src/property.dart
+++ b/dev/core_generator/lib/src/property.dart
@@ -27,6 +27,12 @@
   int? passthroughBit;
   int? passthroughBitWidth;
   Property? bitmaskTargetProperty;
+
+  /// True when this is a bitmask passthrough defined in a mixin whose mask
+  /// ([passthroughForBitmask]) is provided by the host class that includes the
+  /// mixin (e.g. `colorValue` on SolidColor/GradientStop), not a same-def
+  /// sibling. core_registry dispatches these per consuming type.
+  bool bitmaskTargetIsHostProvided = false;
   bool isPureVirtual = false;
   FieldType? typeRuntime;
 
diff --git a/dev/defs/shapes/paint/color_channels.json b/dev/defs/shapes/paint/color_channels.json
new file mode 100644
index 0000000..9493775
--- /dev/null
+++ b/dev/defs/shapes/paint/color_channels.json
@@ -0,0 +1,67 @@
+{
+  "name": "ColorChannels",
+  "key": {
+    "int": 101,
+    "string": "colorchannels"
+  },
+  "abstract": true,
+  "isMixin": true,
+  "properties": {
+    "colorRed": {
+      "type": "uint",
+      "initialValue": "0",
+      "animates": true,
+      "key": {
+        "int": 118,
+        "string": "colorred"
+      },
+      "description": "Red channel (0-255) of the host colorValue. Passthrough view.",
+      "bindable": true,
+      "passthroughForBitmask": "colorValue",
+      "passthroughBit": 16,
+      "passthroughBitWidth": 8
+    },
+    "colorGreen": {
+      "type": "uint",
+      "initialValue": "0",
+      "animates": true,
+      "key": {
+        "int": 136,
+        "string": "colorgreen"
+      },
+      "description": "Green channel (0-255) of the host colorValue. Passthrough view.",
+      "bindable": true,
+      "passthroughForBitmask": "colorValue",
+      "passthroughBit": 8,
+      "passthroughBitWidth": 8
+    },
+    "colorBlue": {
+      "type": "uint",
+      "initialValue": "0",
+      "animates": true,
+      "key": {
+        "int": 210,
+        "string": "colorblue"
+      },
+      "description": "Blue channel (0-255) of the host colorValue. Passthrough view.",
+      "bindable": true,
+      "passthroughForBitmask": "colorValue",
+      "passthroughBit": 0,
+      "passthroughBitWidth": 8
+    },
+    "colorAlpha": {
+      "type": "uint",
+      "initialValue": "0",
+      "animates": true,
+      "key": {
+        "int": 218,
+        "string": "coloralpha"
+      },
+      "description": "Alpha channel (0-255) of the host colorValue. Passthrough view.",
+      "bindable": true,
+      "passthroughForBitmask": "colorValue",
+      "passthroughBit": 24,
+      "passthroughBitWidth": 8
+    }
+  }
+}
\ No newline at end of file
diff --git a/dev/defs/shapes/paint/gradient_stop.json b/dev/defs/shapes/paint/gradient_stop.json
index 4555199..d92be92 100644
--- a/dev/defs/shapes/paint/gradient_stop.json
+++ b/dev/defs/shapes/paint/gradient_stop.json
@@ -5,6 +5,9 @@
     "string": "gradientstop"
   },
   "extends": "component.json",
+  "mixins": [
+    "shapes/paint/color_channels.json"
+  ],
   "properties": {
     "colorValue": {
       "type": "Color",
diff --git a/dev/defs/shapes/paint/solid_color.json b/dev/defs/shapes/paint/solid_color.json
index 7182495..002a319 100644
--- a/dev/defs/shapes/paint/solid_color.json
+++ b/dev/defs/shapes/paint/solid_color.json
@@ -5,6 +5,9 @@
     "string": "solidcolor"
   },
   "extends": "component.json",
+  "mixins": [
+    "shapes/paint/color_channels.json"
+  ],
   "properties": {
     "colorValue": {
       "type": "Color",
diff --git a/include/rive/generated/core_registry.hpp b/include/rive/generated/core_registry.hpp
index 77b82e3..592bc69 100644
--- a/include/rive/generated/core_registry.hpp
+++ b/include/rive/generated/core_registry.hpp
@@ -211,6 +211,7 @@
 #include "rive/event.hpp"
 #include "rive/focus_data.hpp"
 #include "rive/foreground_layout_drawable.hpp"
+#include "rive/generated/shapes/paint/color_channels_base.hpp"
 #include "rive/inputs/gamepad_input.hpp"
 #include "rive/inputs/keyboard_input.hpp"
 #include "rive/inputs/semantic_input.hpp"
@@ -1466,6 +1467,38 @@
             case TargetEffectBase::targetIdPropertyKey:
                 object->as<TargetEffectBase>()->targetId(value);
                 break;
+            case ColorChannelsBase::colorRedPropertyKey:
+            {
+                if (auto* _c = ColorChannelsBase::from(object))
+                {
+                    _c->colorRed(value);
+                }
+                break;
+            }
+            case ColorChannelsBase::colorGreenPropertyKey:
+            {
+                if (auto* _c = ColorChannelsBase::from(object))
+                {
+                    _c->colorGreen(value);
+                }
+                break;
+            }
+            case ColorChannelsBase::colorBluePropertyKey:
+            {
+                if (auto* _c = ColorChannelsBase::from(object))
+                {
+                    _c->colorBlue(value);
+                }
+                break;
+            }
+            case ColorChannelsBase::colorAlphaPropertyKey:
+            {
+                if (auto* _c = ColorChannelsBase::from(object))
+                {
+                    _c->colorAlpha(value);
+                }
+                break;
+            }
             case StrokeBase::capPropertyKey:
                 object->as<StrokeBase>()->cap(value);
                 break;
@@ -3589,6 +3622,30 @@
                 return object->as<ShapePaintBase>()->blendModeValue();
             case TargetEffectBase::targetIdPropertyKey:
                 return object->as<TargetEffectBase>()->targetId();
+            case ColorChannelsBase::colorRedPropertyKey:
+                if (auto* _c = ColorChannelsBase::from(object))
+                {
+                    return _c->colorRed();
+                }
+                return 0u;
+            case ColorChannelsBase::colorGreenPropertyKey:
+                if (auto* _c = ColorChannelsBase::from(object))
+                {
+                    return _c->colorGreen();
+                }
+                return 0u;
+            case ColorChannelsBase::colorBluePropertyKey:
+                if (auto* _c = ColorChannelsBase::from(object))
+                {
+                    return _c->colorBlue();
+                }
+                return 0u;
+            case ColorChannelsBase::colorAlphaPropertyKey:
+                if (auto* _c = ColorChannelsBase::from(object))
+                {
+                    return _c->colorAlpha();
+                }
+                return 0u;
             case StrokeBase::capPropertyKey:
                 return object->as<StrokeBase>()->cap();
             case StrokeBase::joinPropertyKey:
@@ -4657,6 +4714,10 @@
             case BlendStateTransitionBase::exitBlendAnimationIdPropertyKey:
             case ShapePaintBase::blendModeValuePropertyKey:
             case TargetEffectBase::targetIdPropertyKey:
+            case ColorChannelsBase::colorRedPropertyKey:
+            case ColorChannelsBase::colorGreenPropertyKey:
+            case ColorChannelsBase::colorBluePropertyKey:
+            case ColorChannelsBase::colorAlphaPropertyKey:
             case StrokeBase::capPropertyKey:
             case StrokeBase::joinPropertyKey:
             case FeatherBase::spaceValuePropertyKey:
@@ -4735,6 +4796,8 @@
             case TextBase::verticalAlignValuePropertyKey:
             case TextBase::textRunListSourcePropertyKey:
             case TextBase::verticalTrimValuePropertyKey:
+            case TextBase::verticalTrimTopValuePropertyKey:
+            case TextBase::verticalTrimBottomValuePropertyKey:
             case TextValueRunBase::styleIdPropertyKey:
             case ArtboardListMapRuleBase::artboardIdPropertyKey:
             case ArtboardListMapRuleBase::viewModelIdPropertyKey:
@@ -4826,8 +4889,32 @@
             case PointsCommonPathBase::isClosedPropertyKey:
             case RectangleBase::linkCornerRadiusPropertyKey:
             case ClippingShapeBase::isVisiblePropertyKey:
+            case FocusDataBase::canFocusPropertyKey:
+            case FocusDataBase::canTouchPropertyKey:
+            case FocusDataBase::canTraversePropertyKey:
             case CustomPropertyBooleanBase::propertyValuePropertyKey:
             case LayoutComponentBase::clipPropertyKey:
+            case SemanticDataBase::isExpandablePropertyKey:
+            case SemanticDataBase::isSelectablePropertyKey:
+            case SemanticDataBase::isCheckablePropertyKey:
+            case SemanticDataBase::isToggleablePropertyKey:
+            case SemanticDataBase::isRequirablePropertyKey:
+            case SemanticDataBase::isEnablablePropertyKey:
+            case SemanticDataBase::isFocusablePropertyKey:
+            case SemanticDataBase::isExpandedPropertyKey:
+            case SemanticDataBase::isSelectedPropertyKey:
+            case SemanticDataBase::isCheckedPropertyKey:
+            case SemanticDataBase::isMixedPropertyKey:
+            case SemanticDataBase::isToggledPropertyKey:
+            case SemanticDataBase::isRequiredPropertyKey:
+            case SemanticDataBase::isDisabledPropertyKey:
+            case SemanticDataBase::isFocusedPropertyKey:
+            case SemanticDataBase::isHiddenPropertyKey:
+            case SemanticDataBase::isLiveRegionPropertyKey:
+            case SemanticDataBase::isReadOnlyPropertyKey:
+            case SemanticDataBase::isModalPropertyKey:
+            case SemanticDataBase::isObscuredPropertyKey:
+            case SemanticDataBase::isMultilinePropertyKey:
             case DataBindPathBase::isRelativePropertyKey:
             case BindablePropertyBooleanBase::propertyValuePropertyKey:
             case TextModifierRangeBase::clampPropertyKey:
@@ -5423,6 +5510,14 @@
                 return object->is<ShapePaintBase>();
             case TargetEffectBase::targetIdPropertyKey:
                 return object->is<TargetEffectBase>();
+            case ColorChannelsBase::colorRedPropertyKey:
+                return ColorChannelsBase::from(object) != nullptr;
+            case ColorChannelsBase::colorGreenPropertyKey:
+                return ColorChannelsBase::from(object) != nullptr;
+            case ColorChannelsBase::colorBluePropertyKey:
+                return ColorChannelsBase::from(object) != nullptr;
+            case ColorChannelsBase::colorAlphaPropertyKey:
+                return ColorChannelsBase::from(object) != nullptr;
             case StrokeBase::capPropertyKey:
                 return object->is<StrokeBase>();
             case StrokeBase::joinPropertyKey:
diff --git a/include/rive/generated/shapes/paint/color_channels_base.hpp b/include/rive/generated/shapes/paint/color_channels_base.hpp
new file mode 100644
index 0000000..b60fdcd
--- /dev/null
+++ b/include/rive/generated/shapes/paint/color_channels_base.hpp
@@ -0,0 +1,104 @@
+#ifndef _RIVE_COLOR_CHANNELS_BASE_HPP_
+#define _RIVE_COLOR_CHANNELS_BASE_HPP_
+#include <cstdint>
+namespace rive
+{
+class Core;
+class ColorChannelsBase
+{
+public:
+    static ColorChannelsBase* from(Core* object);
+    virtual int colorValue() const = 0;
+    virtual void colorValue(int value) = 0;
+    static const uint16_t colorRedPropertyKey = 118;
+    static const uint32_t colorRedBitOffset = 16;
+    static const uint32_t colorRedFieldMask = 16711680u;
+    uint32_t colorRed() const
+    {
+        return (static_cast<uint32_t>(colorValue()) >> 16) & 255u;
+    }
+    void colorRed(uint32_t value)
+    {
+        if (value > 255u)
+        {
+            value = 255u;
+        }
+        const int _cur = colorValue();
+        const int _fieldMask = static_cast<int>(16711680u);
+        const int _next = static_cast<int>((_cur & ~_fieldMask) |
+                                           ((value << 16) & _fieldMask));
+        if (_cur != _next)
+        {
+            colorValue(_next);
+        }
+    }
+    static const uint16_t colorGreenPropertyKey = 136;
+    static const uint32_t colorGreenBitOffset = 8;
+    static const uint32_t colorGreenFieldMask = 65280u;
+    uint32_t colorGreen() const
+    {
+        return (static_cast<uint32_t>(colorValue()) >> 8) & 255u;
+    }
+    void colorGreen(uint32_t value)
+    {
+        if (value > 255u)
+        {
+            value = 255u;
+        }
+        const int _cur = colorValue();
+        const int _fieldMask = static_cast<int>(65280u);
+        const int _next = static_cast<int>((_cur & ~_fieldMask) |
+                                           ((value << 8) & _fieldMask));
+        if (_cur != _next)
+        {
+            colorValue(_next);
+        }
+    }
+    static const uint16_t colorBluePropertyKey = 210;
+    static const uint32_t colorBlueBitOffset = 0;
+    static const uint32_t colorBlueFieldMask = 255u;
+    uint32_t colorBlue() const
+    {
+        return (static_cast<uint32_t>(colorValue()) >> 0) & 255u;
+    }
+    void colorBlue(uint32_t value)
+    {
+        if (value > 255u)
+        {
+            value = 255u;
+        }
+        const int _cur = colorValue();
+        const int _fieldMask = static_cast<int>(255u);
+        const int _next = static_cast<int>((_cur & ~_fieldMask) |
+                                           ((value << 0) & _fieldMask));
+        if (_cur != _next)
+        {
+            colorValue(_next);
+        }
+    }
+    static const uint16_t colorAlphaPropertyKey = 218;
+    static const uint32_t colorAlphaBitOffset = 24;
+    static const uint32_t colorAlphaFieldMask = 4278190080u;
+    uint32_t colorAlpha() const
+    {
+        return (static_cast<uint32_t>(colorValue()) >> 24) & 255u;
+    }
+    void colorAlpha(uint32_t value)
+    {
+        if (value > 255u)
+        {
+            value = 255u;
+        }
+        const int _cur = colorValue();
+        const int _fieldMask = static_cast<int>(4278190080u);
+        const int _next = static_cast<int>((_cur & ~_fieldMask) |
+                                           ((value << 24) & _fieldMask));
+        if (_cur != _next)
+        {
+            colorValue(_next);
+        }
+    }
+};
+} // namespace rive
+
+#endif
\ No newline at end of file
diff --git a/include/rive/generated/shapes/paint/gradient_stop_base.hpp b/include/rive/generated/shapes/paint/gradient_stop_base.hpp
index d35be56..e73ae15 100644
--- a/include/rive/generated/shapes/paint/gradient_stop_base.hpp
+++ b/include/rive/generated/shapes/paint/gradient_stop_base.hpp
@@ -3,9 +3,10 @@
 #include "rive/component.hpp"
 #include "rive/core/field_types/core_color_type.hpp"
 #include "rive/core/field_types/core_double_type.hpp"
+#include "rive/generated/shapes/paint/color_channels_base.hpp"
 namespace rive
 {
-class GradientStopBase : public Component
+class GradientStopBase : public Component, public ColorChannelsBase
 {
 protected:
     typedef Component Super;
@@ -37,8 +38,8 @@
     float m_Position = 0.0f;
 
 public:
-    inline int colorValue() const { return m_ColorValue; }
-    void colorValue(int value)
+    inline int colorValue() const override { return m_ColorValue; }
+    void colorValue(int value) override
     {
         if (m_ColorValue == value)
         {
diff --git a/include/rive/generated/shapes/paint/solid_color_base.hpp b/include/rive/generated/shapes/paint/solid_color_base.hpp
index 7111c8f..36dbc9d 100644
--- a/include/rive/generated/shapes/paint/solid_color_base.hpp
+++ b/include/rive/generated/shapes/paint/solid_color_base.hpp
@@ -2,9 +2,10 @@
 #define _RIVE_SOLID_COLOR_BASE_HPP_
 #include "rive/component.hpp"
 #include "rive/core/field_types/core_color_type.hpp"
+#include "rive/generated/shapes/paint/color_channels_base.hpp"
 namespace rive
 {
-class SolidColorBase : public Component
+class SolidColorBase : public Component, public ColorChannelsBase
 {
 protected:
     typedef Component Super;
@@ -34,8 +35,8 @@
     int m_ColorValue = 0xFF747474;
 
 public:
-    inline int colorValue() const { return m_ColorValue; }
-    void colorValue(int value)
+    inline int colorValue() const override { return m_ColorValue; }
+    void colorValue(int value) override
     {
         if (m_ColorValue == value)
         {
diff --git a/src/data_bind/context/context_target_value.cpp b/src/data_bind/context/context_target_value.cpp
index e205710..d599384 100644
--- a/src/data_bind/context/context_target_value.cpp
+++ b/src/data_bind/context/context_target_value.cpp
@@ -81,6 +81,10 @@
             {
                 m_targetValue = new DataValueViewModel();
             }
+            else if (dataBind->sourceOutputType() == DataType::number)
+            {
+                m_targetValue = new DataValueNumber();
+            }
             else
             {
                 m_targetValue = new DataValueInteger();
@@ -255,6 +259,16 @@
             {
                 auto value = CoreRegistry::getUint(dataBind->target(),
                                                    dataBind->propertyKey());
+                // Match whatever target value type initialize() created: a
+                // number source (e.g. a color channel bound to a VM number)
+                // reads back as a DataValueNumber; integer/enum sources stay
+                // DataValueInteger. Keying off the real type keeps the two
+                // methods in lockstep regardless of source-resolution timing.
+                if (m_targetValue != nullptr &&
+                    m_targetValue->is<DataValueNumber>())
+                {
+                    return updateValue<DataValueNumber, float>((float)value);
+                }
                 return updateValue<DataValueInteger, int>(value);
             }
         }
diff --git a/src/data_bind/data_bind.cpp b/src/data_bind/data_bind.cpp
index f469b5b..adae98f 100644
--- a/src/data_bind/data_bind.cpp
+++ b/src/data_bind/data_bind.cpp
@@ -413,7 +413,11 @@
         key == ScrollConstraintBase::velocityYPropertyKey ||
         key == ScrollConstraintBase::scrollActivePropertyKey ||
         key == ScrollConstraintBase::computedContentWidthPropertyKey ||
-        key == ScrollConstraintBase::computedContentHeightPropertyKey)
+        key == ScrollConstraintBase::computedContentHeightPropertyKey ||
+        key == ColorChannelsBase::colorAlphaPropertyKey ||
+        key == ColorChannelsBase::colorRedPropertyKey ||
+        key == ColorChannelsBase::colorBluePropertyKey ||
+        key == ColorChannelsBase::colorGreenPropertyKey)
     {
         return false;
     }
diff --git a/src/generated/shapes/paint/color_channels_base.cpp b/src/generated/shapes/paint/color_channels_base.cpp
new file mode 100644
index 0000000..380c114
--- /dev/null
+++ b/src/generated/shapes/paint/color_channels_base.cpp
@@ -0,0 +1,16 @@
+#include "rive/generated/shapes/paint/color_channels_base.hpp"
+#include "rive/core.hpp"
+#include "rive/shapes/paint/solid_color.hpp"
+#include "rive/shapes/paint/gradient_stop.hpp"
+using namespace rive;
+ColorChannelsBase* ColorChannelsBase::from(Core* object)
+{
+    switch (object->coreType())
+    {
+        case SolidColorBase::typeKey:
+            return object->as<SolidColorBase>();
+        case GradientStopBase::typeKey:
+            return object->as<GradientStopBase>();
+    }
+    return nullptr;
+}
diff --git a/src/shapes/paint/gradient_stop.cpp b/src/shapes/paint/gradient_stop.cpp
index 098eea2..d510497 100644
--- a/src/shapes/paint/gradient_stop.cpp
+++ b/src/shapes/paint/gradient_stop.cpp
@@ -21,9 +21,15 @@
 
 void GradientStop::colorValueChanged()
 {
-    parent()->as<LinearGradient>()->markGradientDirty();
+    if (parent() != nullptr && parent()->is<LinearGradient>())
+    {
+        parent()->as<LinearGradient>()->markGradientDirty();
+    }
 }
 void GradientStop::positionChanged()
 {
-    parent()->as<LinearGradient>()->markStopsDirty();
+    if (parent() != nullptr && parent()->is<LinearGradient>())
+    {
+        parent()->as<LinearGradient>()->markStopsDirty();
+    }
 }
\ No newline at end of file
diff --git a/tests/unit_tests/assets/color_passthrough_test.riv b/tests/unit_tests/assets/color_passthrough_test.riv
new file mode 100644
index 0000000..9058178
--- /dev/null
+++ b/tests/unit_tests/assets/color_passthrough_test.riv
Binary files differ
diff --git a/tests/unit_tests/runtime/color_channels_test.cpp b/tests/unit_tests/runtime/color_channels_test.cpp
new file mode 100644
index 0000000..99ece99
--- /dev/null
+++ b/tests/unit_tests/runtime/color_channels_test.cpp
@@ -0,0 +1,178 @@
+#include <rive/core/field_types/core_uint_type.hpp>
+#include <rive/generated/core_registry.hpp>
+#include <rive/generated/shapes/paint/color_channels_base.hpp>
+#include <rive/node.hpp>
+#include <rive/shapes/paint/gradient_stop.hpp>
+#include <rive/shapes/paint/solid_color.hpp>
+#include <utils/serializing_factory.hpp>
+#include <rive_file_reader.hpp>
+#include <catch.hpp>
+#include <rive/animation/state_machine_instance.hpp>
+
+// The per-channel color properties (colorRed/Green/Blue/Alpha) are shared by
+// SolidColor and GradientStop via the ColorChannels mixin. They are passthrough
+// views into the packed ARGB colorValue: they store nothing of their own and
+// both components share the same property keys.
+
+using namespace rive;
+
+TEST_CASE("color channels read the right byte of colorValue", "[color]")
+{
+    SolidColor solid;
+    solid.colorValue((int)0xAABBCCDD);
+    CHECK(solid.colorAlpha() == 0xAAu);
+    CHECK(solid.colorRed() == 0xBBu);
+    CHECK(solid.colorGreen() == 0xCCu);
+    CHECK(solid.colorBlue() == 0xDDu);
+
+    GradientStop stop;
+    stop.colorValue((int)0x11223344);
+    CHECK(stop.colorAlpha() == 0x11u);
+    CHECK(stop.colorRed() == 0x22u);
+    CHECK(stop.colorGreen() == 0x33u);
+    CHECK(stop.colorBlue() == 0x44u);
+}
+
+TEST_CASE("setting a channel writes only its byte and recomposes colorValue",
+          "[color]")
+{
+    SolidColor solid;
+    solid.colorValue((int)0xAABBCCDD);
+
+    solid.colorGreen(0x11u);
+    CHECK((uint32_t)solid.colorValue() == 0xAABB11DDu);
+    // Other channels untouched.
+    CHECK(solid.colorAlpha() == 0xAAu);
+    CHECK(solid.colorRed() == 0xBBu);
+    CHECK(solid.colorBlue() == 0xDDu);
+
+    solid.colorAlpha(0x00u);
+    CHECK((uint32_t)solid.colorValue() == 0x00BB11DDu);
+}
+
+TEST_CASE("channels clamp to 255 instead of wrapping", "[color]")
+{
+    SolidColor solid;
+    solid.colorValue((int)0x00000000);
+
+    // A value wider than the 8-bit field saturates at 255 rather than
+    // overflowing into the neighbouring byte (300 & 0xFF would wrap to 44).
+    solid.colorRed(300u);
+    CHECK(solid.colorRed() == 0xFFu);
+    CHECK((uint32_t)solid.colorValue() == 0x00FF0000u);
+
+    // Same guarantee through the registry (data binding / animation path).
+    CoreRegistry::setUint(&solid,
+                          ColorChannelsBase::colorAlphaPropertyKey,
+                          1000u);
+    CHECK(CoreRegistry::getUint(&solid,
+                                ColorChannelsBase::colorAlphaPropertyKey) ==
+          0xFFu);
+    CHECK((uint32_t)solid.colorValue() == 0xFFFF0000u);
+}
+
+TEST_CASE("ColorChannelsBase::from resolves consumers, null otherwise",
+          "[color]")
+{
+    SolidColor solid;
+    GradientStop stop;
+    Node node;
+
+    CHECK(ColorChannelsBase::from(&solid) != nullptr);
+    CHECK(ColorChannelsBase::from(&stop) != nullptr);
+    // A Core object that does not include the mixin resolves to null.
+    CHECK(ColorChannelsBase::from(&node) == nullptr);
+
+    // The resolved interface reads/writes the host mask.
+    solid.colorValue((int)0xFF000000);
+    ColorChannelsBase::from(&solid)->colorRed(0x80u);
+    CHECK((uint32_t)solid.colorValue() == 0xFF800000u);
+}
+
+TEST_CASE("shared channel keys dispatch through CoreRegistry for both types",
+          "[color]")
+{
+    SolidColor solid;
+    GradientStop stop;
+
+    // The SAME property keys route to both concrete types.
+    CoreRegistry::setUint(&solid,
+                          ColorChannelsBase::colorRedPropertyKey,
+                          0x34u);
+    CoreRegistry::setUint(&stop, ColorChannelsBase::colorRedPropertyKey, 0x34u);
+    CHECK(
+        CoreRegistry::getUint(&solid, ColorChannelsBase::colorRedPropertyKey) ==
+        0x34u);
+    CHECK(
+        CoreRegistry::getUint(&stop, ColorChannelsBase::colorRedPropertyKey) ==
+        0x34u);
+    CHECK(solid.colorRed() == 0x34u);
+    CHECK(stop.colorRed() == 0x34u);
+
+    // A channel write is a masked read-modify-write of colorValue.
+    solid.colorValue((int)0x00000000);
+    CoreRegistry::setUint(&solid,
+                          ColorChannelsBase::colorAlphaPropertyKey,
+                          0xCDu);
+    CHECK((uint32_t)solid.colorValue() == 0xCD000000u);
+    CHECK(CoreRegistry::getUint(&solid,
+                                ColorChannelsBase::colorAlphaPropertyKey) ==
+          0xCDu);
+}
+
+TEST_CASE("objectSupportsProperty is true for channels on consumers only",
+          "[color]")
+{
+    SolidColor solid;
+    GradientStop stop;
+    Node node;
+
+    CHECK(CoreRegistry::objectSupportsProperty(
+        &solid,
+        ColorChannelsBase::colorRedPropertyKey));
+    CHECK(CoreRegistry::objectSupportsProperty(
+        &stop,
+        ColorChannelsBase::colorAlphaPropertyKey));
+    CHECK_FALSE(CoreRegistry::objectSupportsProperty(
+        &node,
+        ColorChannelsBase::colorRedPropertyKey));
+}
+
+TEST_CASE("channel keys report a uint field type for data binding", "[color]")
+{
+    // propertyFieldId must resolve the channels (they are uint-valued) so data
+    // binding can build the right target value instead of crashing on -1.
+    CHECK(CoreRegistry::propertyFieldId(
+              ColorChannelsBase::colorRedPropertyKey) == +CoreUintType::id);
+    CHECK(CoreRegistry::propertyFieldId(
+              ColorChannelsBase::colorAlphaPropertyKey) == +CoreUintType::id);
+}
+
+TEST_CASE("Silver test of passthrough properties", "[silver]")
+{
+    SerializingFactory silver;
+    auto file = ReadRiveFile("assets/color_passthrough_test.riv", &silver);
+    auto artboard = file->artboardDefault();
+    REQUIRE(artboard != nullptr);
+
+    silver.frameSize(artboard->width(), artboard->height());
+
+    auto stateMachine = artboard->stateMachineAt(0);
+
+    auto vmi = file->createDefaultViewModelInstance(artboard.get());
+
+    stateMachine->bindViewModelInstance(vmi);
+    stateMachine->advanceAndApply(0.1f);
+    auto renderer = silver.makeRenderer();
+    artboard->draw(renderer.get());
+
+    int frames = (int)(3.0f / 0.25f);
+    for (int i = 0; i < frames; i++)
+    {
+        silver.addFrame();
+        stateMachine->advanceAndApply(0.25f);
+        artboard->draw(renderer.get());
+    }
+
+    CHECK(silver.matches("color_passthrough_test"));
+}
diff --git a/tests/unit_tests/silvers/color_passthrough_test.sriv b/tests/unit_tests/silvers/color_passthrough_test.sriv
new file mode 100644
index 0000000..b5e11e5
--- /dev/null
+++ b/tests/unit_tests/silvers/color_passthrough_test.sriv
Binary files differ