@@ -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,169 @@ 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+ // A variable id becomes a CSS custom-property name (`--{id}`), a `data-var-*`
955+ // attribute value, and a CLI `--variables` key. isCompositionVariable only
956+ // checks it is a non-empty string, so the SDK — the last gate before Studio /
957+ // CSS / CLI make those assumptions — enforces a safe identifier shape here.
958+ const VALID_VARIABLE_ID = / ^ [ A - Z a - z _ ] [ A - Z a - z 0 - 9 _ - ] * $ / ;
959+
960+ function isValidVariableId ( id : string ) : boolean {
961+ return VALID_VARIABLE_ID . test ( id ) ;
962+ }
963+
964+ function invalidVariableIdErr ( id : string ) : CanResult {
965+ return canErr (
966+ "E_INVALID_VARIABLE_ID" ,
967+ `Variable id ${ JSON . stringify ( id ) } is not a valid identifier.` ,
968+ "Ids must match /^[A-Za-z_][A-Za-z0-9_-]*$/ — they become CSS custom-property names (--id), data-var-* attribute values, and CLI --variables keys." ,
969+ ) ;
970+ }
971+
972+ /**
973+ * Shared can() precondition for declareVariable/updateVariableDeclaration:
974+ * refuse fragment compositions, non-declaration shapes, and malformed ids.
975+ * Returns the CanResult to surface, or null when the declaration is well-formed.
976+ * The shape check runs before the id access so a null/non-object declaration
977+ * yields a CanResult, not a TypeError.
978+ */
979+ function declarationPreconditionErr (
980+ parsed : ParsedDocument ,
981+ declaration : CompositionVariable ,
982+ ) : CanResult | null {
983+ const fragmentErr = fragmentCompositionErr ( parsed ) ;
984+ if ( fragmentErr ) return fragmentErr ;
985+ if ( ! isCompositionVariable ( declaration ) ) return invalidDeclarationErr ( ) ;
986+ if ( ! isValidVariableId ( declaration . id ) ) return invalidVariableIdErr ( declaration . id ) ;
987+ return null ;
988+ }
989+
990+ function handleDeclareVariable (
991+ parsed : ParsedDocument ,
992+ declaration : CompositionVariable ,
993+ ) : MutationResult {
994+ // Defensive re-check of can(): never write an invalid or duplicate
995+ // declaration into the schema. Fragment sources have no <html> of their
996+ // own — writing to the synthetic wrapper would be lost on serialize.
997+ if ( parsed . wrapped ) return EMPTY ;
998+ if ( ! isCompositionVariable ( declaration ) ) return EMPTY ;
999+ if ( ! isValidVariableId ( declaration . id ) ) return EMPTY ;
1000+ if ( findVariableDeclaration ( parsed . document , declaration . id ) !== undefined ) return EMPTY ;
1001+ if ( ! writeVariableDeclaration ( parsed . document , declaration ) ) return EMPTY ;
1002+ const path = variableDeclPath ( declaration . id ) ;
1003+ const result : MutationResult = {
1004+ forward : [ patchAdd ( path , declaration ) ] ,
1005+ inverse : [ patchRemove ( path ) ] ,
1006+ } ;
1007+ // Same CSS compat channel every other variable op maintains — a composition
1008+ // CSS-bound to var(--id) must resolve regardless of which op set the value.
1009+ if ( isScalar ( declaration . default ) ) {
1010+ const css = cssCompatChange ( parsed , declaration . id , String ( declaration . default ) ) ;
1011+ if ( css ) {
1012+ result . forward . push ( css . forward ) ;
1013+ result . inverse . push ( css . inverse ) ;
1014+ }
1015+ }
1016+ return result ;
1017+ }
1018+
1019+ function handleUpdateVariableDeclaration (
1020+ parsed : ParsedDocument ,
1021+ id : string ,
1022+ declaration : CompositionVariable ,
1023+ ) : MutationResult {
1024+ if ( parsed . wrapped ) return EMPTY ;
1025+ if ( ! isCompositionVariable ( declaration ) || declaration . id !== id ) return EMPTY ;
1026+ const old = findVariableDeclaration ( parsed . document , id ) ;
1027+ if ( old === undefined ) return EMPTY ;
1028+ writeVariableDeclaration ( parsed . document , declaration ) ;
1029+ const p = valueChange ( variableDeclPath ( id ) , old , declaration ) ;
1030+ const result : MutationResult = { forward : [ p . forward ] , inverse : [ p . inverse ] } ;
1031+
1032+ // Default changed → keep the CSS compat prop in sync (set for scalars,
1033+ // clear when the new default is object-valued font/image), and emit the
1034+ // paired /variables value patch so the T3 override-set's var.{id} entry
1035+ // agrees with the varDecl.{id} snapshot regardless of replay order.
1036+ const oldDefault = old . default ;
1037+ const newDefault = declaration . default ;
1038+ if ( JSON . stringify ( oldDefault ) !== JSON . stringify ( newDefault ) ) {
1039+ const valueP = valueChange ( variablePath ( id ) , oldDefault ?? null , newDefault ) ;
1040+ result . forward . push ( valueP . forward ) ;
1041+ result . inverse . push ( valueP . inverse ) ;
1042+ const css = cssCompatChange ( parsed , id , isScalar ( newDefault ) ? String ( newDefault ) : null ) ;
1043+ if ( css ) {
1044+ result . forward . push ( css . forward ) ;
1045+ result . inverse . push ( css . inverse ) ;
1046+ }
1047+ }
1048+ return result ;
1049+ }
1050+
1051+ function handleRemoveVariableDeclaration ( parsed : ParsedDocument , id : string ) : MutationResult {
1052+ if ( parsed . wrapped ) return EMPTY ;
1053+ const old = findVariableDeclaration ( parsed . document , id ) ;
1054+ if ( old === undefined ) return EMPTY ;
1055+ removeVariableDeclarationEntry ( parsed . document , id ) ;
1056+ const path = variableDeclPath ( id ) ;
1057+ const result : MutationResult = {
1058+ forward : [ patchRemove ( path ) ] ,
1059+ inverse : [ patchAdd ( path , old ) ] ,
1060+ } ;
1061+ const css = cssCompatChange ( parsed , id , null ) ;
1062+ if ( css ) {
1063+ result . forward . push ( css . forward ) ;
1064+ result . inverse . push ( css . inverse ) ;
1065+ }
1066+ return result ;
1067+ }
1068+
8881069// ─── GSAP selector helpers ───────────────────────────────────────────────────
8891070
8901071function selectorMatchesId ( selector : string , id : HfId ) : boolean {
@@ -1494,6 +1675,45 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
14941675 if ( findRoot ( parsed . document ) === null )
14951676 return canErr ( "E_NO_ROOT" , "Composition root element not found." ) ;
14961677 return CAN_OK ;
1678+ case "declareVariable" : {
1679+ const preErr = declarationPreconditionErr ( parsed , op . declaration ) ;
1680+ if ( preErr ) return preErr ;
1681+ if ( findVariableDeclaration ( parsed . document , op . declaration . id ) !== undefined )
1682+ return canErr (
1683+ "E_DUPLICATE_VARIABLE" ,
1684+ `Variable "${ op . declaration . id } " is already declared.` ,
1685+ "Use updateVariableDeclaration to change it, or setVariableValue to change its default." ,
1686+ ) ;
1687+ return CAN_OK ;
1688+ }
1689+ case "updateVariableDeclaration" : {
1690+ const preErr = declarationPreconditionErr ( parsed , op . declaration ) ;
1691+ if ( preErr ) return preErr ;
1692+ if ( op . declaration . id !== op . id )
1693+ return canErr (
1694+ "E_INVALID_ARGS" ,
1695+ `declaration.id ("${ op . declaration . id } ") must match id ("${ op . id } ").` ,
1696+ "Variable ids are immutable — rename via removeVariableDeclaration + declareVariable." ,
1697+ ) ;
1698+ if ( findVariableDeclaration ( parsed . document , op . id ) === undefined )
1699+ return canErr (
1700+ "E_VARIABLE_NOT_FOUND" ,
1701+ `Variable "${ op . id } " is not declared.` ,
1702+ "Check comp.getVariableDeclarations(), or add it with declareVariable." ,
1703+ ) ;
1704+ return CAN_OK ;
1705+ }
1706+ case "removeVariableDeclaration" : {
1707+ const fragmentErr = fragmentCompositionErr ( parsed ) ;
1708+ if ( fragmentErr ) return fragmentErr ;
1709+ if ( findVariableDeclaration ( parsed . document , op . id ) === undefined )
1710+ return canErr (
1711+ "E_VARIABLE_NOT_FOUND" ,
1712+ `Variable "${ op . id } " is not declared.` ,
1713+ "Check comp.getVariableDeclarations()." ,
1714+ ) ;
1715+ return CAN_OK ;
1716+ }
14971717 case "setCompositionMetadata" :
14981718 case "setClassStyle" :
14991719 return CAN_OK ;
0 commit comments