@@ -40,6 +40,7 @@ import {
4040 holdPath ,
4141 elementPath ,
4242 variablePath ,
43+ variableDeclPath ,
4344 metaPath ,
4445 gsapScriptPath ,
4546 styleSheetPath ,
@@ -76,7 +77,18 @@ import {
7677 unrollDynamicAnimations ,
7778} from "@hyperframes/core/gsap-writer-acorn" ;
7879import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js" ;
79- import { readVariableDefault , writeVariableDefault } from "./variableModel.js" ;
80+ import {
81+ readVariableDefault ,
82+ writeVariableDefault ,
83+ findVariableDeclaration ,
84+ writeVariableDeclaration ,
85+ removeVariableDeclarationEntry ,
86+ } from "./variableModel.js" ;
87+ import {
88+ isCompositionVariable ,
89+ isScalarVariableValue as isScalar ,
90+ } from "@hyperframes/core/variables" ;
91+ import type { CompositionVariable } from "@hyperframes/core/variables" ;
8092import {
8193 URI_BEARING_ATTRS ,
8294 DANGEROUS_URI_SCHEMES ,
@@ -288,6 +300,12 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
288300 return handleSetCompositionMetadata ( parsed , op ) ;
289301 case "setVariableValue" :
290302 return handleSetVariableValue ( parsed , op . id , op . value ) ;
303+ case "declareVariable" :
304+ return handleDeclareVariable ( parsed , op . declaration ) ;
305+ case "updateVariableDeclaration" :
306+ return handleUpdateVariableDeclaration ( parsed , op . id , op . declaration ) ;
307+ case "removeVariableDeclaration" :
308+ return handleRemoveVariableDeclaration ( parsed , op . id ) ;
291309 case "setClassStyle" :
292310 return handleSetClassStyle ( parsed , op . selector , op . styles ) ;
293311 case "addLabel" :
@@ -885,6 +903,132 @@ function handleSetVariableValue(
885903 return { forward, inverse } ;
886904}
887905
906+ /**
907+ * Keep the `--{id}` CSS compat custom property on the root in sync with a
908+ * scalar default (same secondary channel handleSetVariableValue maintains).
909+ * Pass null to clear. Returns the patch pair, or null when there is no root
910+ * or nothing to change.
911+ */
912+ function cssCompatChange (
913+ parsed : ParsedDocument ,
914+ id : string ,
915+ newVal : string | null ,
916+ ) : { forward : JsonPatchOp ; inverse : JsonPatchOp } | null {
917+ const root = findRoot ( parsed . document ) ;
918+ const rootId = root ?. getAttribute ( "data-hf-id" ) ;
919+ if ( ! root || ! rootId ) return null ;
920+ const cssVar = `--${ id } ` ;
921+ const oldCssValue = getElementStyles ( root ) [ cssVar ] ?? null ;
922+ if ( newVal !== null ) {
923+ if ( oldCssValue === newVal ) return null ;
924+ setElementStyles ( root , { [ cssVar ] : newVal } ) ;
925+ return scalarChange ( stylePath ( rootId , cssVar ) , oldCssValue , newVal ) ;
926+ }
927+ if ( oldCssValue === null ) return null ;
928+ setElementStyles ( root , { [ cssVar ] : null } ) ;
929+ return scalarDelete ( stylePath ( rootId , cssVar ) , oldCssValue ) ;
930+ }
931+
932+ /**
933+ * Declaration ops require a real `<html>` in the source: fragment inputs get
934+ * a synthetic wrapper that serialize() strips, so a declaration written there
935+ * would silently vanish on save.
936+ */
937+ function fragmentCompositionErr ( parsed : ParsedDocument ) : CanResult | null {
938+ if ( ! parsed . wrapped ) return null ;
939+ return canErr (
940+ "E_FRAGMENT_COMPOSITION" ,
941+ "Fragment compositions cannot carry variable declarations." ,
942+ "data-composition-variables lives on the <html> element — convert the composition to a full HTML document first." ,
943+ ) ;
944+ }
945+
946+ function invalidDeclarationErr ( ) : CanResult {
947+ return canErr (
948+ "E_INVALID_ARGS" ,
949+ "Not a valid variable declaration." ,
950+ "Requires id, label, type (string|number|color|boolean|enum|font|image), and a default matching the type; enum also requires options[]." ,
951+ ) ;
952+ }
953+
954+ function handleDeclareVariable (
955+ parsed : ParsedDocument ,
956+ declaration : CompositionVariable ,
957+ ) : MutationResult {
958+ // Defensive re-check of can(): never write an invalid or duplicate
959+ // declaration into the schema. Fragment sources have no <html> of their
960+ // own — writing to the synthetic wrapper would be lost on serialize.
961+ if ( parsed . wrapped ) return EMPTY ;
962+ if ( ! isCompositionVariable ( declaration ) ) return EMPTY ;
963+ if ( findVariableDeclaration ( parsed . document , declaration . id ) !== undefined ) return EMPTY ;
964+ if ( ! writeVariableDeclaration ( parsed . document , declaration ) ) return EMPTY ;
965+ const path = variableDeclPath ( declaration . id ) ;
966+ const result : MutationResult = {
967+ forward : [ patchAdd ( path , declaration ) ] ,
968+ inverse : [ patchRemove ( path ) ] ,
969+ } ;
970+ // Same CSS compat channel every other variable op maintains — a composition
971+ // CSS-bound to var(--id) must resolve regardless of which op set the value.
972+ if ( isScalar ( declaration . default ) ) {
973+ const css = cssCompatChange ( parsed , declaration . id , String ( declaration . default ) ) ;
974+ if ( css ) {
975+ result . forward . push ( css . forward ) ;
976+ result . inverse . push ( css . inverse ) ;
977+ }
978+ }
979+ return result ;
980+ }
981+
982+ function handleUpdateVariableDeclaration (
983+ parsed : ParsedDocument ,
984+ id : string ,
985+ declaration : CompositionVariable ,
986+ ) : MutationResult {
987+ if ( parsed . wrapped ) return EMPTY ;
988+ if ( ! isCompositionVariable ( declaration ) || declaration . id !== id ) return EMPTY ;
989+ const old = findVariableDeclaration ( parsed . document , id ) ;
990+ if ( old === undefined ) return EMPTY ;
991+ writeVariableDeclaration ( parsed . document , declaration ) ;
992+ const p = valueChange ( variableDeclPath ( id ) , old , declaration ) ;
993+ const result : MutationResult = { forward : [ p . forward ] , inverse : [ p . inverse ] } ;
994+
995+ // Default changed → keep the CSS compat prop in sync (set for scalars,
996+ // clear when the new default is object-valued font/image), and emit the
997+ // paired /variables value patch so the T3 override-set's var.{id} entry
998+ // agrees with the varDecl.{id} snapshot regardless of replay order.
999+ const oldDefault = old . default ;
1000+ const newDefault = declaration . default ;
1001+ if ( JSON . stringify ( oldDefault ) !== JSON . stringify ( newDefault ) ) {
1002+ const valueP = valueChange ( variablePath ( id ) , oldDefault ?? null , newDefault ) ;
1003+ result . forward . push ( valueP . forward ) ;
1004+ result . inverse . push ( valueP . inverse ) ;
1005+ const css = cssCompatChange ( parsed , id , isScalar ( newDefault ) ? String ( newDefault ) : null ) ;
1006+ if ( css ) {
1007+ result . forward . push ( css . forward ) ;
1008+ result . inverse . push ( css . inverse ) ;
1009+ }
1010+ }
1011+ return result ;
1012+ }
1013+
1014+ function handleRemoveVariableDeclaration ( parsed : ParsedDocument , id : string ) : MutationResult {
1015+ if ( parsed . wrapped ) return EMPTY ;
1016+ const old = findVariableDeclaration ( parsed . document , id ) ;
1017+ if ( old === undefined ) return EMPTY ;
1018+ removeVariableDeclarationEntry ( parsed . document , id ) ;
1019+ const path = variableDeclPath ( id ) ;
1020+ const result : MutationResult = {
1021+ forward : [ patchRemove ( path ) ] ,
1022+ inverse : [ patchAdd ( path , old ) ] ,
1023+ } ;
1024+ const css = cssCompatChange ( parsed , id , null ) ;
1025+ if ( css ) {
1026+ result . forward . push ( css . forward ) ;
1027+ result . inverse . push ( css . inverse ) ;
1028+ }
1029+ return result ;
1030+ }
1031+
8881032// ─── GSAP selector helpers ───────────────────────────────────────────────────
8891033
8901034function selectorMatchesId ( selector : string , id : HfId ) : boolean {
@@ -1494,6 +1638,49 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
14941638 if ( findRoot ( parsed . document ) === null )
14951639 return canErr ( "E_NO_ROOT" , "Composition root element not found." ) ;
14961640 return CAN_OK ;
1641+ case "declareVariable" : {
1642+ const fragmentErr = fragmentCompositionErr ( parsed ) ;
1643+ if ( fragmentErr ) return fragmentErr ;
1644+ if ( ! isCompositionVariable ( op . declaration ) ) return invalidDeclarationErr ( ) ;
1645+ if ( findVariableDeclaration ( parsed . document , op . declaration . id ) !== undefined )
1646+ return canErr (
1647+ "E_DUPLICATE_VARIABLE" ,
1648+ `Variable "${ op . declaration . id } " is already declared.` ,
1649+ "Use updateVariableDeclaration to change it, or setVariableValue to change its default." ,
1650+ ) ;
1651+ return CAN_OK ;
1652+ }
1653+ case "updateVariableDeclaration" : {
1654+ const fragmentErr = fragmentCompositionErr ( parsed ) ;
1655+ if ( fragmentErr ) return fragmentErr ;
1656+ // Shape check FIRST — a null/non-object declaration must yield a
1657+ // CanResult, not a TypeError from the id access below.
1658+ if ( ! isCompositionVariable ( op . declaration ) ) return invalidDeclarationErr ( ) ;
1659+ if ( op . declaration . id !== op . id )
1660+ return canErr (
1661+ "E_INVALID_ARGS" ,
1662+ `declaration.id ("${ op . declaration . id } ") must match id ("${ op . id } ").` ,
1663+ "Variable ids are immutable — rename via removeVariableDeclaration + declareVariable." ,
1664+ ) ;
1665+ if ( findVariableDeclaration ( parsed . document , op . id ) === undefined )
1666+ return canErr (
1667+ "E_VARIABLE_NOT_FOUND" ,
1668+ `Variable "${ op . id } " is not declared.` ,
1669+ "Check comp.getVariableDeclarations(), or add it with declareVariable." ,
1670+ ) ;
1671+ return CAN_OK ;
1672+ }
1673+ case "removeVariableDeclaration" : {
1674+ const fragmentErr = fragmentCompositionErr ( parsed ) ;
1675+ if ( fragmentErr ) return fragmentErr ;
1676+ if ( findVariableDeclaration ( parsed . document , op . id ) === undefined )
1677+ return canErr (
1678+ "E_VARIABLE_NOT_FOUND" ,
1679+ `Variable "${ op . id } " is not declared.` ,
1680+ "Check comp.getVariableDeclarations()." ,
1681+ ) ;
1682+ return CAN_OK ;
1683+ }
14971684 case "setCompositionMetadata" :
14981685 case "setClassStyle" :
14991686 return CAN_OK ;
0 commit comments