ADR
Gradient Support for Color Style Properties
Context
Anova’s types/Styles.ts defines backgroundColor, textColor, and strokes as Style values — the catch-all union for any serialisable style property. The schema, however, already has a more specific definition for these three fields:
In schema/styles.schema.json, ColorStyleValue is used for all color-bearing properties:
ColorStyleValue: oneOf: - type: string # hex/rgba - $ref: VariableStyle - $ref: FigmaStyle - type: nullFigma uses gradient fills (LINEAR, RADIAL, ANGULAR, and DIAMOND paint types) as a first-class alternative to solid color fills. DIAMOND is Figma-only and has no native equivalent across CSS, iOS, or Android — it is excluded from this ADR. When a node’s backgroundColor, textColor, or strokes is a gradient, the current contract has no representation for it. The outcomes today are:
- Named gradient style (
fillStyleIdset): captured asFigmaStyle { id }— handled. - Variable bound to gradient fill: captured as
VariableStyle { id, rawValue }—rawValueis whatever the raw color processor returns (likelynullfor non-solid). - Inline gradient fill (no style, no variable): falls through
raw()→ColorStyle.value()→ emitsnullbecause no solid paint is present.
The gap is the inline gradient case and the lack of a structured cross-platform shape when gradient data should be emitted rather than just a style reference. backgroundColor and textColor are treated symmetrically to strokes in the fill-style detection pipeline (FILL_SPEC_KEYS covers all three).
This ADR also surfaces a type-schema asymmetry: the schema already isolates color properties under ColorStyleValue, but types/Styles.ts uses the catch-all Style for those fields with no corresponding ColorStyle TypeScript type. This ADR closes both gaps simultaneously.
Decision Drivers
- Type-schema symmetry (Constitution I): Any change to
types/Styles.tsmust have a matching change inschema/styles.schema.jsonbefore publish. - No logic permitted (Constitution II): Only types, interfaces, and schema definitions. No gradient parsing algorithms belong here.
- Stable, intentional public API (Constitution III): New types must represent a genuine shared concept — not Figma internals.
GradientValuemust be cross-platform; Figma-internal constructs (affine transform matrices, DIAMOND type) must be excluded. - Minimal structure (Constitution III): The type must capture what is needed for cross-platform use, not faithfully mirror Figma’s
GradientPaint(which uses transform matrices and per-stop alpha separately from color). - Additive = MINOR (Constitution versioning): Adding new optional variants to existing type unions without removing existing ones qualifies as MINOR if no consumer’s existing valid code breaks.
- Strict TypeScript (Constitution V): The new interface must compile cleanly under
tsconfig.build.jsonwith noany. fillsarrays are out of scope: Anova maps Figma’sfillsarray down to the semantic propertiesbackgroundColor,textColor, andstrokes— the rawfillsarray is never surfaced in the type or schema contract. Gradient support therefore applies only to those mapped properties; there is no requirement to represent Figma’s multi-fill array or its ordering.
Options Considered
Option A: Discriminated GradientValue + new ColorStyle type (Selected)
Introduce a discriminated union GradientValue = LinearGradient | RadialGradient | AngularGradient exported from types/Gradient.ts. Introduce a ColorStyle type alias in types/Styles.ts that mirrors ColorStyleValue in the schema. Update backgroundColor, textColor, and strokes to use ColorStyle instead of Style.
# New GradientStop interfaceGradientStop: position: number # 0–1 along the gradient vector color: string | VariableStyle # hex/rgba or variable reference
# Discriminated variantsLinearGradient: type: "LINEAR" angle: number # degrees — required stops: GradientStop[]
RadialGradient: type: "RADIAL" center: { x: number; y: number } # normalised 0–1 — required stops: GradientStop[]
AngularGradient: type: "ANGULAR" center: { x: number; y: number } # normalised 0–1 — required stops: GradientStop[]
GradientValue: LinearGradient | RadialGradient | AngularGradient
# New ColorStyle type (TypeScript mirror of ColorStyleValue schema)ColorStyle: oneOf: - string # hex/rgba solid color - VariableStyle # variable reference - FigmaStyle # named Figma style reference - ReferenceValue # prop binding - GradientValue # ← new — inline gradient - nullPros:
- Discriminated union enforces required fields per type at compile time — no optional
angleleaking onto RADIAL/ANGULAR shapes stops[].colorsupports both raw hex and variable references, matching how Figma lets individual stops be bound to variablesColorStylecloses the type-schema asymmetry: TypeScript consumers now have a named type that maps directly toColorStyleValuein the schema- DIAMOND excluded — only
LINEAR | RADIAL | ANGULARare cross-platform - Additive —
Styleunion is unchanged; onlybackgroundColor,textColor,strokesfields are narrowed toColorStyle - Named-style (
FigmaStyle) and variable (VariableStyle) paths are preserved unchanged
Cons / Trade-offs:
- Narrowing
backgroundColor/textColor/strokesfromStyletoColorStyleis technically a type-level narrowing — existing code assigning abooleantobackgroundColorwould fail to compile, but this was never semantically valid and is unlikely in practice anova-kitconsumers narrowingStylevalues for color fields must update toColorStyle
Option B: Figma GradientPaint model (Rejected)
Expose Figma’s own GradientPaint shape: { type, gradientTransform: Transform, gradientStops: [{ position, color: {r, g, b, a} }] }.
Rejected because: gradientTransform is a 3×2 affine matrix that is Figma-specific and carries no semantic meaning outside Figma. It couples the Anova contract permanently to a single tool’s internal representation, violating the cross-platform intent of Constitution III (“genuine, shared concept”).
Option C: CSS-like string (Rejected)
Accept gradient as a raw CSS string, e.g. "linear-gradient(90deg, #000 0%, #fff 100%)". No new types added — Style already accepts string.
Rejected because: This is structurally opaque. Downstream tools (anova-kit, anova-plugin) cannot parse or validate gradient intent without reimplementing a CSS gradient parser. It also excludes non-CSS platforms (iOS, Android) from consuming a structured value. Constitution III requires types to represent genuine shared concepts — hiding structure inside a string is the opposite.
Option D: References only — no inline gradient values (Rejected)
Do nothing. Gradients must always be expressed via a named Figma style (FigmaStyle) or variable (VariableStyle). Inline gradients are emitted as null.
Rejected because: This is a lossy contract — a component with an inline gradient fill would silently emit null, making the output schema-valid but semantically wrong. It also does not address the user question about whether backgroundColor / textColor are handled correctly (they are, via the fill-style path, but the inline case remains broken).
Option E: Platform-specific definitions (Rejected)
Add keyed sub-objects per platform, e.g. { css: "linear-gradient(...)", swiftUI: { ... }, compose: { ... } }.
Rejected because: This couples the shared type contract to n platform grammars simultaneously, multiplying the maintenance surface in violation of Constitution III (minimal, stable API). Platform adaptation belongs in downstream tooling, not in the shared type definition.
Decision
Type changes (types/)
| File | Change | Bump |
|---|---|---|
Gradient.ts | New file — exports GradientStop, GradientCenter, LinearGradient, RadialGradient, AngularGradient, GradientValue | MINOR |
Styles.ts | New ColorStyle type alias; backgroundColor, textColor, strokes narrowed from Style to ColorStyle | MINOR |
index.ts | Add exports for all types from Gradient.ts; add ColorStyle | MINOR |
Example — new types/Gradient.ts:
# types/Gradient.ts — new file
GradientCenter: # reusable point type for RADIAL / ANGULAR x: number # normalised 0–1 y: number # normalised 0–1
GradientStop: position: number # 0–1 color: string | VariableStyle # hex/rgba or variable reference
LinearGradient: type: "LINEAR" angle: number # degrees — required stops: GradientStop[]
RadialGradient: type: "RADIAL" center: GradientCenter # required stops: GradientStop[]
AngularGradient: type: "ANGULAR" center: GradientCenter # required stops: GradientStop[]
GradientValue: LinearGradient | RadialGradient | AngularGradientExample — new ColorStyle type in types/Styles.ts:
# Before: backgroundColor / textColor / strokes typed as Style# (Style is the catch-all union and includes boolean, number, etc.)
# After: a ColorStyle type mirrors ColorStyleValue from the schemaColorStyle: oneOf: - string # hex/rgba solid - VariableStyle - FigmaStyle - ReferenceValue - GradientValue # ← new - null
# Styles type — affected fieldsStyles: backgroundColor: ColorStyle # was: Style textColor: ColorStyle # was: Style strokes: ColorStyle # was: Style # all other fields: Style unchangedSchema changes (schema/)
| File | Change | Bump |
|---|---|---|
styles.schema.json | New definitions GradientCenter, GradientStop, LinearGradient, RadialGradient, AngularGradient, GradientValue; ColorStyleValue gains { "$ref": "#/definitions/GradientValue" } | MINOR |
Example — new schema definitions:
# Under #/definitions
GradientCenter: type: object required: [x, y] properties: x: type: number minimum: 0 maximum: 1 description: "Horizontal centre position, normalised 0–1" y: type: number minimum: 0 maximum: 1 description: "Vertical centre position, normalised 0–1" additionalProperties: false
GradientStop: type: object required: [position, color] properties: position: type: number minimum: 0 maximum: 1 description: "Stop position along the gradient vector (0–1)" color: oneOf: - type: string description: "Stop color as hex or rgba string" - $ref: "#/definitions/VariableStyle" description: "Stop color as a variable reference" additionalProperties: false
LinearGradient: type: object required: [type, angle, stops] properties: type: type: string const: "LINEAR" angle: type: number description: "Angle in degrees" stops: type: array items: $ref: "#/definitions/GradientStop" minItems: 2 additionalProperties: false
RadialGradient: type: object required: [type, center, stops] properties: type: type: string const: "RADIAL" center: $ref: "#/definitions/GradientCenter" stops: type: array items: $ref: "#/definitions/GradientStop" minItems: 2 additionalProperties: false
AngularGradient: type: object required: [type, center, stops] properties: type: type: string const: "ANGULAR" center: $ref: "#/definitions/GradientCenter" stops: type: array items: $ref: "#/definitions/GradientStop" minItems: 2 additionalProperties: false
GradientValue: oneOf: - $ref: "#/definitions/LinearGradient" - $ref: "#/definitions/RadialGradient" - $ref: "#/definitions/AngularGradient"
# Updated ColorStyleValueColorStyleValue: oneOf: - type: string # hex/rgba (solid, no change) - $ref: VariableStyle # no change - $ref: FigmaStyle # no change — named gradient style - $ref: GradientValue # ← new — inline gradient - type: null # no changeNotes
backgroundColor,textColor, andstrokesall referenceColorStyleValuein the schema; all three gain gradient support implicitly via this single definition change.- DIAMOND is intentionally excluded — it is not natively representable in CSS, SwiftUI, or Jetpack Compose without approximation, making it unsuitable for the cross-platform contract.
- Each gradient variant is a separate schema object with
conston itstypefield, enabling JSON Schema validators to discriminate correctly. GradientValueis distinguishable fromFigmaStyle/VariableStyle/ReferenceValueat runtime via the presence of thetypefield with a gradient-specific constant value — thetypekey does not appear on any otherColorStylevariant.- No dedicated
Angleschema definition is introduced. CSSlinear-gradient()and equivalent platform APIs treat angle as a wrapping modular value:-45deg,405deg, and315degare all semantically valid in different contexts. Aminimum: 0, maximum: 360constraint would cause false validation failures for legitimately out-of-range but equivalent values. Theanglefield remainstype: numberwith a description of"Angle in degrees"— sufficient to communicate the unit without imposing invalid bounds.
Rationale
- Platform Neutrality:
LINEAR,RADIAL, andANGULARmap directly to native gradient APIs across CSS, SwiftUI, and Jetpack Compose — no platform-specific translation layer is required to consume the shared type. - Flexibility: Allowing
stops[].colorto be either a raw hex/rgba string or aVariableStylereference means individual stop colors can participate in token-based theming without requiring the entire gradient to be wrapped in a named style. - Simplicity: A discriminated union of three concrete variants is easier to narrow and validate than a single flat object with optional fields — each variant carries only the fields that are semantically meaningful for its type.
- Completeness: Introducing
ColorStylealongsideGradientValuecloses the existing type-schema asymmetry, ensuring every color-bearing property has a precise TypeScript type that corresponds 1:1 withColorStyleValuein the schema. - Consistency: Using
typeas the discriminant key aligns with the existing pattern inEffectsGroupand Figma’s own paint model, making the contract familiar to consumers already working with those types. - Maintainability: Each gradient variant (
LinearGradient,RadialGradient,AngularGradient) is a standalone interface with no shared mutable state, so adding or adjusting a variant in a future ADR requires only an additive change to theGradientValueunion — no existing variants need modification.
Type ↔ Schema Impact
- Symmetric: Yes
- Parity check:
GradientCenter(TypeScript) ↔#/definitions/GradientCenter(schema)GradientStop(TypeScript) ↔#/definitions/GradientStop(schema)LinearGradient(TypeScript) ↔#/definitions/LinearGradient(schema)RadialGradient(TypeScript) ↔#/definitions/RadialGradient(schema)AngularGradient(TypeScript) ↔#/definitions/AngularGradient(schema)GradientValuediscriminated union ↔#/definitions/GradientValueoneOf (schema)ColorStyletype alias ↔#/definitions/ColorStyleValue(schema)
Downstream Impact
| Consumer | Impact | Action required |
|---|---|---|
anova-kit | Recompile; any code narrowing backgroundColor, textColor, or strokes must handle GradientValue | Add type discriminant guard ('LINEAR' | 'RADIAL' | 'ANGULAR') in any switch/if chain over ColorStyle values |
Semver Decision
Version bump: 0.11.0 → 0.12.0 (MINOR)
Justification: All changes are additive — new types (GradientCenter, GradientStop, LinearGradient, RadialGradient, AngularGradient, GradientValue, ColorStyle) and a narrowing of three existing fields in Styles from the catch-all Style to the more precise ColorStyle. No existing valid gradient-assignment code would have compiled against these fields before (no gradient type existed), so there is no regression. The Style union itself is unchanged. Per Constitution versioning rules: “MINOR for additive types or new optional fields.”
Consequences
backgroundColor,textColor, andstrokescan now represent inline gradients in the serialized output — no data is silently dropped tonullfor in-file gradient fills.- The new
ColorStyletype closes the type-schema asymmetry for color properties: TypeScript consumers now have a named type that maps directly toColorStyleValuein the schema. - Named gradient Figma styles continue to be emitted as
FigmaStyle { id }— the reference path is unchanged and preferred. - Variable-bound gradients continue to emit as
VariableStyle { id, rawValue }—rawValuesemantics for gradient variables are deferred (a separate ADR may address whatrawValuemeans when the variable resolves to a gradient). - Individual gradient stops may have their color bound to a variable via
VariableStyle— this is captured inGradientStop.color. anova-kitconsumers must handleGradientValuein any code that narrowed over color-property values. TypeScript will surface missing cases at compile time via thetypediscriminant ('LINEAR' | 'RADIAL' | 'ANGULAR').- DIAMOND gradients remain out-of-scope. A future MAJOR ADR would be required to add them, as DIAMOND has no stable cross-platform equivalent.
- The
typediscriminant field does not collide with any existingColorStylevariant shape, enabling safe runtime narrowing withoutinstanceofchecks.