Source file mask_gradient.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
(** Mask gradient utilities for creating gradient masks.

    Provides utilities for mask-t-from-*, mask-t-to-*, mask-r-from-*,
    mask-r-to-*, mask-b-from-*, mask-b-to-*, mask-l-from-*, mask-l-to-*,
    mask-x-from-*, mask-x-to-*, mask-y-from-*, mask-y-to-*, mask-linear-from-*,
    mask-linear-to-*, mask-radial-*, mask-radial-from-*, mask-radial-to-*,
    mask-conic-from-*, mask-conic-to-*. *)

module Css = Cascade.Css

module Handler = struct
  open Style

  let pp_int = Pp.int
  let pp_float = Pp.float

  open Css

  type direction =
    | Top
    | Right
    | Bottom
    | Left
    | X
    | Y
    | Linear
    | Radial
    | Conic

  type position_end = From | To

  type value =
    | Spacing of float (* calc(var(--spacing) * N) *)
    | Percent of float (* N% *)
    | Arbitrary of string (* [value] *)

  type radial_shape = Circle | Ellipse

  type radial_size =
    | Closest_corner
    | Closest_side
    | Farthest_corner
    | Farthest_side
    | Arbitrary_size of string

  type radial_at_position =
    | Keyword of string (* keywords like "bottom", "top left" *)
    | Bracket of
        string (* arbitrary values like "25%" - stored without brackets *)

  type mask_angle =
    | Int of int (* mask-linear-45 → calc(1deg * 45); mask-linear-1 → 1deg *)
    | Arb of string
      (* mask-linear-[3rad] → 171.887deg, read and converted at style time *)
    | Arb_neg of string
  (* -mask-linear-[3rad] → calc(171.887deg * -1) *)

  (* The colour a stop names, as the class spelled it. *)
  type stop_color =
    | Palette of Color.color * int (* red-500 *)
    | Keyword_color of Css.color * string (* transparent, current, inherit *)
    | Bracket_color of string
      (* [#fff], [red], [color:var(--c)]: the bracket's inner text as written *)
    | Color_var of string (* (color:--x): the name after the dashes *)

  type t =
    | Position of direction * position_end * value
    | Var_ref of direction * position_end * string option * string
      (* (--x) or (<hint>:--x), the position read from a custom property: the
         hint as written and the name after the dashes *)
    | Stop of direction * position_end * stop_color * Color.opacity_modifier
    | Linear_angle of mask_angle
    | Conic_angle of mask_angle
    | Radial (* just mask-radial with no position *)
    | Radial_at of radial_at_position (* mask-radial-at-* *)
    | Radial_shape of radial_shape (* mask-circle, mask-ellipse *)
    | Radial_size of radial_size (* mask-radial-closest-corner etc. *)

  let name = "mask_gradient"

  (* Tailwind emits the mask-gradient utilities after the backgrounds and before
     the other masks, which come before fill/stroke and padding. *)
  let priority _ = 21

  let direction_short = function
    | Top -> "t"
    | Right -> "r"
    | Bottom -> "b"
    | Left -> "l"
    | X -> "x"
    | Y -> "y"
    | Linear -> "linear"
    | Radial -> "radial"
    | Conic -> "conic"

  let position_end_name = function From -> "from" | To -> "to"

  (* When using Spacing values, we need to emit the theme declaration for
     --spacing so that :root, :host { --spacing: .25rem } appears *)
  let spacing_theme_decl value =
    match value with
    | Spacing _ ->
        let decl = Var.set Theme.spacing_var Theme.spacing_base in
        [ decl ]
    | Percent _ | Arbitrary _ -> []

  (* Common mask image using List of three var references *)
  let mask_image_list : background_image =
    List
      [
        background_image_var_none "tw-mask-linear";
        background_image_var_none "tw-mask-radial";
        background_image_var_none "tw-mask-conic";
      ]

  (* A mask direction owns five variables: the slot holding the gradient it
     contributes to [mask-image], and the colour and position of each of that
     gradient's two stops. The names are written out rather than assembled, so
     every one of them is a value the compiler checks. *)
  type stop_vars = {
    slot : Css.background_image Var.channel;
    from_position : Css.length_percentage Var.channel;
    to_position : Css.length_percentage Var.channel;
    from_color : Css.color Var.channel;
    to_color : Css.color Var.channel;
  }

  let stop_vars ~slot ~from_position ~to_position ~from_color ~to_color =
    {
      slot = Var.channel Background_image slot;
      from_position = Var.channel Length_percentage from_position;
      to_position = Var.channel Length_percentage to_position;
      from_color = Var.channel Color from_color;
      to_color = Var.channel Color to_color;
    }

  let top_vars =
    stop_vars ~slot:"tw-mask-top" ~from_position:"tw-mask-top-from-position"
      ~to_position:"tw-mask-top-to-position"
      ~from_color:"tw-mask-top-from-color" ~to_color:"tw-mask-top-to-color"

  let right_vars =
    stop_vars ~slot:"tw-mask-right" ~from_position:"tw-mask-right-from-position"
      ~to_position:"tw-mask-right-to-position"
      ~from_color:"tw-mask-right-from-color" ~to_color:"tw-mask-right-to-color"

  let bottom_vars =
    stop_vars ~slot:"tw-mask-bottom"
      ~from_position:"tw-mask-bottom-from-position"
      ~to_position:"tw-mask-bottom-to-position"
      ~from_color:"tw-mask-bottom-from-color"
      ~to_color:"tw-mask-bottom-to-color"

  let left_vars =
    stop_vars ~slot:"tw-mask-left" ~from_position:"tw-mask-left-from-position"
      ~to_position:"tw-mask-left-to-position"
      ~from_color:"tw-mask-left-from-color" ~to_color:"tw-mask-left-to-color"

  let linear_vars =
    stop_vars ~slot:"tw-mask-linear"
      ~from_position:"tw-mask-linear-from-position"
      ~to_position:"tw-mask-linear-to-position"
      ~from_color:"tw-mask-linear-from-color"
      ~to_color:"tw-mask-linear-to-color"

  let radial_vars =
    stop_vars ~slot:"tw-mask-radial"
      ~from_position:"tw-mask-radial-from-position"
      ~to_position:"tw-mask-radial-to-position"
      ~from_color:"tw-mask-radial-from-color"
      ~to_color:"tw-mask-radial-to-color"

  let conic_vars =
    stop_vars ~slot:"tw-mask-conic" ~from_position:"tw-mask-conic-from-position"
      ~to_position:"tw-mask-conic-to-position"
      ~from_color:"tw-mask-conic-from-color" ~to_color:"tw-mask-conic-to-color"

  (* [mask-x] and [mask-y] write both edges of their axis, so they own no
     variables themselves. *)
  let vars_for = function
    | Top -> top_vars
    | Right -> right_vars
    | Bottom -> bottom_vars
    | Left -> left_vars
    | Linear -> linear_vars
    | Radial -> radial_vars
    | Conic -> conic_vars
    | (X | Y) as d ->
        invalid_arg ("mask-" ^ direction_short d ^ " has no variables")

  (* Everything between a gradient's parentheses, so a stop utility can replace
     one stop without restating the others. *)
  let linear_stops_var = Var.channel Gradient_stop "tw-mask-linear-stops"
  let conic_stops_var = Var.channel Gradient_stop "tw-mask-conic-stops"
  let radial_stops_var = Var.channel Gradient_stop "tw-mask-radial-stops"

  (* The radial gradient's own geometry, which its stops read alongside the two
     colour stops every direction owns. *)
  let radial_shape_var = Var.channel Radial_shape "tw-mask-radial-shape"
  let radial_size_var = Var.channel Radial_size "tw-mask-radial-size"
  let radial_position_var = Var.channel Position_value "tw-mask-radial-position"

  (* The angle [mask-linear-45] and [mask-conic-45] turn their gradient by. *)
  let linear_position_var = Var.channel Angle "tw-mask-linear-position"
  let conic_position_var = Var.channel Angle "tw-mask-conic-position"

  (* The [@property] initial values. A utility reads variables it does not set
     itself, so the initial is both what the browser starts from and what the
     inline renderer puts in the reference's place. *)
  let white_gradient : background_image =
    Linear_gradient
      ( Default_direction,
        [
          Color_percentage (hex "#fff", None, None);
          Color_percentage (hex "#fff", None, None);
        ] )

  let start_position : length_percentage = Pct 0.
  let end_position : length_percentage = Pct 100.
  let start_color : Css.color = Named Black
  let end_color : Css.color = Transparent
  let no_rotation : angle = Deg 0.
  let default_shape : Css.radial_shape = Ellipse
  let default_size : Css.radial_size = Farthest_corner
  let default_position : position_value = Center

  (* Reading a mask variable. The [@property] rule gives it an initial value, so
     the reference needs no fallback of its own. *)
  let read v initial = snd (Var.binding v initial)

  (* Writing one. *)
  let set v value = Var.set v value

  (* The two stops a mask gradient runs between. *)
  let stops v : gradient_stop list =
    [
      Color_percentage
        ( Var (read v.from_color start_color),
          Some (Var (read v.from_position start_position)),
          None );
      Color_percentage
        ( Var (read v.to_color end_color),
          Some (Var (read v.to_position end_position)),
          None );
    ]

  let edge_of : direction -> gradient_direction * stop_vars = function
    | Top -> (To_top, top_vars)
    | Right -> (To_right, right_vars)
    | Bottom -> (To_bottom, bottom_vars)
    | Left -> (To_left, left_vars)
    | (X | Y | Linear | Radial | Conic) as d ->
        invalid_arg ("mask-" ^ direction_short d ^ " is not an edge")

  (* [--tw-mask-<edge>] runs its two stops towards that edge. *)
  let edge_gradient dir =
    let side, v = edge_of dir in
    set v.slot (Linear_gradient (side, stops v))

  (* A directional utility layers the four edges into [--tw-mask-linear]. The
     edges it did not touch still contribute their initial white gradient, which
     masks nothing. *)
  let edge_composition =
    set linear_vars.slot
      (List
         [
           Var (read left_vars.slot white_gradient);
           Var (read right_vars.slot white_gradient);
           Var (read bottom_vars.slot white_gradient);
           Var (read top_vars.slot white_gradient);
         ])

  let linear_stops : gradient_stop =
    List
      (Direction (Angle (Var (read linear_position_var no_rotation)))
      :: stops linear_vars)

  let conic_stops : gradient_stop =
    List
      (Position
         (Conic_position
            (conic_gradient_config
               ~angle:(Var (read conic_position_var no_rotation))
               ()))
      :: stops conic_vars)

  let linear_stops_decls =
    let decl, stops_ref = Var.binding linear_stops_var linear_stops in
    [ decl; set linear_vars.slot (Linear_gradient_var stops_ref) ]

  let conic_stops_decls =
    let decl, stops_ref = Var.binding conic_stops_var conic_stops in
    [ decl; set conic_vars.slot (Conic_gradient_var stops_ref) ]

  (* The radial gradient reads its shape, size and position out of variables, so
     a shape or size utility can move one of them without restating the stops.
     [radial_geometry] is that [<ending-shape> <size> at <position>] prelude. *)
  let radial_geometry : gradient_position =
    Radial_position
      (radial_gradient_config
         ~shape:(Var (read radial_shape_var default_shape))
         ~size:(Var (read radial_size_var default_size))
         ~position:(Var (read radial_position_var default_position))
         ())

  let radial_stops : gradient_stop =
    List (Position radial_geometry :: stops radial_vars)

  let radial_stops_decls =
    let decl, stops_ref = Var.binding radial_stops_var radial_stops in
    [ decl; set radial_vars.slot (Radial_gradient_var stops_ref) ]

  (* Common composite declarations *)
  let composite_decls =
    [
      webkit_mask_composite Source_in;
      webkit_mask_composite Source_in;
      mask_composite Intersect;
    ]

  (* Helper to create mask-image declarations using typed functions *)
  let mask_image_decls =
    [
      webkit_mask_image mask_image_list;
      webkit_mask_image mask_image_list;
      mask_image mask_image_list;
    ]

  (* @property rule helpers. *)
  let prop ?(order = 100) name initial =
    let _ = order in
    property ~name:("--tw-mask-" ^ name) Universal ~initial_value:initial
      ~inherits:false ()

  (* Common @property rules for mask-image vars *)
  let common_property_rules =
    concat
      [
        prop ~order:55 "linear" "linear-gradient(#fff, #fff)";
        prop ~order:56 "radial" "linear-gradient(#fff, #fff)";
        prop ~order:57 "conic" "linear-gradient(#fff, #fff)";
      ]

  (* @property rules for directional gradient vars (left, right, bottom, top) *)
  let directional_gradient_property_rules =
    concat
      [
        prop ~order:58 "left" "linear-gradient(#fff, #fff)";
        prop ~order:59 "right" "linear-gradient(#fff, #fff)";
        prop ~order:60 "bottom" "linear-gradient(#fff, #fff)";
        prop ~order:61 "top" "linear-gradient(#fff, #fff)";
      ]

  (* @property rules for a specific direction's from/to vars. The order offset
     varies by axis so that e.g. right endpoints sort before left endpoints in
     the properties layer. A direction keeps one slot whichever utility emits
     it: [mask-l-*] and the left half of [mask-x-*] name the same four custom
     properties, so both ask for 66. *)
  let direction_endpoint_rules ?(order_base = 62) dir_name =
    concat
      [
        prop ~order:order_base (dir_name ^ "-from-position") "0%";
        prop ~order:(order_base + 1) (dir_name ^ "-to-position") "100%";
        prop ~order:(order_base + 2) (dir_name ^ "-from-color") "black";
        prop ~order:(order_base + 3) (dir_name ^ "-to-color") "transparent";
      ]

  (* Pre-compute property rules for all directions at module load time to avoid
     re-registering property orders dynamically during tests. X/Y use different
     order_bases so their sub-directions sort correctly in properties layer. *)
  let top_property_rules =
    concat
      [
        common_property_rules;
        directional_gradient_property_rules;
        direction_endpoint_rules "top";
      ]

  let right_property_rules =
    concat
      [
        common_property_rules;
        directional_gradient_property_rules;
        direction_endpoint_rules "right";
      ]

  let bottom_property_rules =
    concat
      [
        common_property_rules;
        directional_gradient_property_rules;
        direction_endpoint_rules ~order_base:66 "bottom";
      ]

  let left_property_rules =
    concat
      [
        common_property_rules;
        directional_gradient_property_rules;
        direction_endpoint_rules ~order_base:66 "left";
      ]

  let x_property_rules =
    concat
      [
        common_property_rules;
        directional_gradient_property_rules;
        direction_endpoint_rules ~order_base:62 "right";
        direction_endpoint_rules ~order_base:66 "left";
      ]

  let y_property_rules =
    concat
      [
        common_property_rules;
        directional_gradient_property_rules;
        direction_endpoint_rules ~order_base:62 "top";
        direction_endpoint_rules ~order_base:66 "bottom";
      ]

  (* Linear/radial/conic property rules always include the position @property
     (used by both angle and from/to modes) *)
  let linear_property_rules =
    concat
      [
        common_property_rules;
        concat [ prop ~order:61 "linear-position" "0deg" ];
        direction_endpoint_rules "linear";
      ]

  let radial_property_rules =
    concat
      [
        common_property_rules;
        direction_endpoint_rules "radial";
        concat
          [
            prop ~order:70 "radial-shape" "ellipse";
            prop ~order:71 "radial-size" "farthest-corner";
            prop ~order:72 "radial-position" "center";
          ];
      ]

  let conic_property_rules =
    concat
      [
        common_property_rules;
        concat [ prop ~order:61 "conic-position" "0deg" ];
        direction_endpoint_rules "conic";
      ]

  let property_rules_for_direction = function
    | Top -> top_property_rules
    | Right -> right_property_rules
    | Bottom -> bottom_property_rules
    | Left -> left_property_rules
    | X -> x_property_rules
    | Y -> y_property_rules
    | Linear -> linear_property_rules
    | Radial -> radial_property_rules
    | Conic -> conic_property_rules

  (* A stop is a <length-percentage>. A bracket value cascade cannot read goes
     into the sheet as the author wrote it. *)
  let position_var v = function From -> v.from_position | To -> v.to_position
  let color_var v = function From -> v.from_color | To -> v.to_color

  let arbitrary_length_percentage s : length_percentage option =
    match
      Cascade.Cursor.try_parse_full_err Css.Values.read_length_percentage
        (Cascade.Cursor.of_string s)
    with
    | Ok lp -> Some lp
    | Error _ -> None

  (* The value a bracket carries: the text after any data-type hint, where [_]
     is a space and a math function takes the spaces around its operators. The
     hint chose the longhand and says nothing about the value. *)
  let bracket_value inner =
    Parse.decode_arbitrary_value
      (Option.value (Parse.value_after_hint inner) ~default:inner)

  let position_decl ?theme v pos_end value =
    let var = position_var v pos_end in
    match value with
    | Spacing 0. ->
        (* The zero spacing step keeps its unit ([0px]) here, where a bare [0]
           would be the folded length. *)
        set var (Length (Px 0.))
    | Spacing n ->
        let _, len = Theme.spacing_calc_float ?theme n in
        set var (Length len)
    | Percent p -> set var (Pct p)
    | Arbitrary raw -> (
        (* Whether or not what comes out is a length-percentage, it reaches the
           sheet: the position is a token stream. *)
        let value = bracket_value raw in
        match arbitrary_length_percentage value with
        | Some lp -> set var lp
        | None -> custom_property ~layer:"utilities" (Var.css_name var) value)

  (* Build the style for a directional mask position *)
  let build_directional_style ?theme dir pos_end value =
    let property_rules = property_rules_for_direction dir in

    (* Common declarations for all directional masks *)
    let common_decls =
      spacing_theme_decl value @ mask_image_decls
      @ [
          edge_composition;
          edge_gradient dir;
          position_decl ?theme (vars_for dir) pos_end value;
        ]
    in
    style ~property_rules (common_decls @ composite_decls)

  (* Build the style for mask-x (both left and right) *)
  let build_x_style ?theme pos_end value =
    let common_decls =
      spacing_theme_decl value @ mask_image_decls
      @ [
          edge_composition;
          (* Right group first, then left group — interleaved order *)
          edge_gradient Right;
          position_decl ?theme right_vars pos_end value;
          edge_gradient Left;
          position_decl ?theme left_vars pos_end value;
        ]
    in
    style ~property_rules:x_property_rules (common_decls @ composite_decls)

  (* Build the style for mask-y (both top and bottom) *)
  let build_y_style ?theme pos_end value =
    let common_decls =
      spacing_theme_decl value @ mask_image_decls
      @ [
          edge_composition;
          (* Top group first, then bottom group — interleaved order *)
          edge_gradient Top;
          position_decl ?theme top_vars pos_end value;
          edge_gradient Bottom;
          position_decl ?theme bottom_vars pos_end value;
        ]
    in
    style ~property_rules:y_property_rules (common_decls @ composite_decls)

  (* Build the style for mask-linear (generic linear gradient) *)
  let build_linear_style ?theme pos_end value =
    let common_decls =
      spacing_theme_decl value @ mask_image_decls @ linear_stops_decls
      @ [ position_decl ?theme linear_vars pos_end value ]
    in
    style ~property_rules:linear_property_rules (common_decls @ composite_decls)

  (* Build the style for mask-radial position *)
  let build_radial_style ?theme pos_end value =
    let common_decls =
      spacing_theme_decl value @ mask_image_decls @ radial_stops_decls
      @ [ position_decl ?theme radial_vars pos_end value ]
    in
    style ~property_rules:radial_property_rules (common_decls @ composite_decls)

  (* Build the style for mask-radial (no position) - produces no output in
     Tailwind *)
  let build_radial_base_style = style []

  (* An arbitrary [mask-radial-at-[30%_30%]] is a position value, so it goes
     through the value printer rather than reaching the sheet as written. *)
  let radial_at_position raw : Css.position_value option =
    match
      String.split_on_char ' ' (Parse.decode_arbitrary_value raw)
      |> List.filter (fun s -> s <> "")
    with
    | [ x; y ] -> (
        match (Css.parse_length x, Css.parse_length y) with
        | Some xv, Some yv -> Some (XY (xv, yv))
        | _ -> None)
    | [ v ] ->
        Option.map
          (fun (l : Css.length) : Css.position_value -> Single l)
          (Css.parse_length v)
    | _ -> None

  (* A bracket [mask-radial-at-*] is a position value, and of_class has already
     read it as one. The keyword form is the class's own words: it admits runs
     no position value carries ([center center], [top top]), so it goes into the
     sheet as the author spelled it. *)
  let build_radial_at_style pos =
    match pos with
    | Bracket s -> (
        match radial_at_position s with
        | Some p -> style [ set radial_position_var p ]
        | None ->
            style
              [
                custom_property ~layer:"utilities"
                  (Var.css_name radial_position_var)
                  s;
              ])
    | Keyword s ->
        style
          [
            custom_property ~layer:"utilities"
              (Var.css_name radial_position_var)
              s;
          ]

  (* Build the style for mask-circle/mask-ellipse *)
  let build_radial_shape_style (shape : radial_shape) =
    let shape : Css.radial_shape =
      match shape with Circle -> Circle | Ellipse -> Ellipse
    in
    style [ set radial_shape_var shape ]

  (* [mask-radial-[...]] takes whatever the author wrote, so a size cascade can
     read becomes a typed radius and anything else goes into the sheet as
     written. *)
  let radial_size_decl s =
    let typed : Css.radial_size option =
      match String.split_on_char ' ' s with
      | [ one ] ->
          Option.map
            (fun l : Css.radial_size -> Circle_radius l)
            (Css.parse_length one)
      | [ x; y ] -> (
          match
            (arbitrary_length_percentage x, arbitrary_length_percentage y)
          with
          | Some x, Some y -> Some (Ellipse_radii (x, y))
          | _ -> None)
      | _ -> None
    in
    match typed with
    | Some size -> set radial_size_var size
    | None ->
        custom_property ~layer:"utilities" (Var.css_name radial_size_var) s

  (* Build the style for mask-radial size keywords and arbitrary sizes *)
  let build_radial_size_style (size : radial_size) =
    let size_decl =
      match size with
      | Closest_corner -> set radial_size_var Closest_corner
      | Closest_side -> set radial_size_var Closest_side
      | Farthest_corner -> set radial_size_var Farthest_corner
      | Farthest_side -> set radial_size_var Farthest_side
      | Arbitrary_size s -> radial_size_decl s
    in
    (* Arbitrary sizes get full mask setup with property_rules, keywords just
       set the variable without property rules *)
    match size with
    | Arbitrary_size _ ->
        (* With no stops of its own the gradient falls back to the bare size,
           which is the whole argument list a size utility leaves. *)
        let stops_ref =
          Var.reference_with_fallback radial_stops_var
            (Position
               (Radial_position
                  (radial_gradient_config
                     ~size:(Var (read radial_size_var default_size))
                     ())))
        in
        let common_decls =
          mask_image_decls
          @ [ set radial_vars.slot (Radial_gradient_var stops_ref); size_decl ]
        in
        style ~property_rules:radial_property_rules
          (common_decls @ composite_decls)
    | _ -> style [ size_decl ]

  (* Build the style for mask-conic position *)
  let build_conic_style ?theme pos_end value =
    let common_decls =
      spacing_theme_decl value @ mask_image_decls @ conic_stops_decls
      @ [ position_decl ?theme conic_vars pos_end value ]
    in
    style ~property_rules:conic_property_rules (common_decls @ composite_decls)

  (* Tailwind writes an arbitrary radian or turn angle into the position
     variable as degrees; every other unit goes in as the author spelled it. *)
  let to_degrees : angle -> angle =
    let deg n : angle = Deg (Float.round (n *. 1000.0) /. 1000.0) in
    function
    | Rad n -> deg (n *. 180.0 /. Float.pi)
    | Turn n -> deg (n *. 360.0)
    | a -> a

  (* A bracket angle cascade cannot read is kept as the tokens the author wrote,
     so it still reaches the sheet through a typed angle. *)
  let invalid_angle s : angle =
    Invalid (Css.Values.read_invalid_value (Cascade.Cursor.of_string s))

  let arbitrary_angle s : angle option =
    match
      Cascade.Cursor.try_parse_full_err Css.Values.read_angle
        (Cascade.Cursor.of_string s)
    with
    | Ok a -> Some (to_degrees a)
    | Error _ -> None

  (* [--tw-mask-*-position] is an opaque [syntax: "*"] custom property, so the
     value reproduces Tailwind's own output: a bare degree for the trivial
     multipliers -1/0/1, and [calc(1deg * n)] otherwise. *)
  let angle_position_decl var angle =
    let negated a : angle = Calc (Expr (Val a, Mul, Num (-1.0))) in
    match angle with
    | Int n when n >= -1 && n <= 1 -> set var (Deg (float_of_int n))
    | Int n -> set var (Calc (Expr (Val (Deg 1.0), Mul, Num (float_of_int n))))
    (* A bracket value that is not an angle still reaches the sheet, negation
       and all, because the variable takes any token stream and Tailwind writes
       the text through. On its own it is carried as the spec-invalid angle it
       is; negated it is spelled out, because a typed [calc] over an invalid
       operand loses the spaces around the operator that both tools write. *)
    | Arb s ->
        set var (Option.value (arbitrary_angle s) ~default:(invalid_angle s))
    | Arb_neg s -> (
        match arbitrary_angle s with
        | Some a -> set var (negated a)
        | None ->
            custom_property ~layer:"utilities" (Var.css_name var)
              ("calc(" ^ s ^ " * -1)"))

  (* The angle shorthands take the whole gradient from the position variable
     unless a stop utility has filled the stops variable in. *)
  let build_linear_angle_style angle =
    let stops_ref =
      Var.reference_with_fallback linear_stops_var
        (Direction (Angle (Var (read linear_position_var no_rotation))))
    in
    let decls =
      mask_image_decls
      @ [
          set linear_vars.slot (Linear_gradient_var stops_ref);
          angle_position_decl linear_position_var angle;
        ]
    in
    style ~property_rules:linear_property_rules (decls @ composite_decls)

  let build_conic_angle_style angle =
    let stops_ref =
      Var.reference_with_fallback conic_stops_var
        (Direction (Angle (Var (read conic_position_var no_rotation))))
    in
    let decls =
      mask_image_decls
      @ [
          set conic_vars.slot (Conic_gradient_var stops_ref);
          angle_position_decl conic_position_var angle;
        ]
    in
    style ~property_rules:conic_property_rules (decls @ composite_decls)

  (* A stop utility restates the gradient it belongs to, then writes its own end
     of it. *)
  let stop_decls dir stop =
    let dir_decls =
      match dir with
      | X ->
          [ edge_gradient Right; stop right_vars ]
          @ [ edge_gradient Left; stop left_vars ]
      | Y ->
          [ edge_gradient Top; stop top_vars ]
          @ [ edge_gradient Bottom; stop bottom_vars ]
      | Linear -> linear_stops_decls @ [ stop linear_vars ]
      | Radial -> radial_stops_decls @ [ stop radial_vars ]
      | Conic -> conic_stops_decls @ [ stop conic_vars ]
      | Top | Right | Bottom | Left ->
          [ edge_gradient dir; stop (vars_for dir) ]
    in
    (* Linear, radial and conic own the whole mask-image slot, so only the four
       edges have to layer themselves into [--tw-mask-linear]. *)
    let linear_decl =
      match dir with
      | Linear | Radial | Conic -> []
      | Top | Right | Bottom | Left | X | Y -> [ edge_composition ]
    in
    mask_image_decls @ linear_decl @ dir_decls

  (* Build the style for parenthesized var reference setting position *)
  let build_var_ref_style dir pos_end name =
    let property_rules = property_rules_for_direction dir in
    let merge_key =
      "mask-" ^ direction_short dir ^ "-" ^ position_end_name pos_end
      ^ "-var-position"
    in
    let position : length_percentage = Var (Var.bracket name) in
    let stop v = set (position_var v pos_end) position in
    style ~merge_key ~property_rules (stop_decls dir stop @ composite_decls)

  (* The colour a bracket stop names, read the way every colour family reads a
     bracket: a var() is the reference it makes, the rest goes through the
     colour reader. [None] is text the reader has no colour for. *)
  let bracket_colour inner : Css.color option =
    let value = Option.value (Parse.value_after_hint inner) ~default:inner in
    if Parse.is_var value then
      Option.some (Color.bracket_var_color (Parse.decode_arbitrary_value value))
    else Color.parse_bracket_color value

  (* The [current] keyword goes in as text because this custom property is a
     token stream: a typed [Current] folds through it to a hex, where Tailwind
     writes [currentcolor] for a gradient stop to resolve against the element.
     Not a workaround for cascade's printer, which spells the keyword correctly
     and positionally - [currentColor] bare, [currentcolor] inside a
     function. *)
  let colour_decl_of (c : Css.color) var =
    match c with
    | Current ->
        custom_property ~layer:"utilities" (Var.css_name var) "currentcolor"
    | c -> set var c

  (* What a stop colour writes: the theme token a palette entry brings along, so
     the sheet defines what it references, the stop every browser reads in the
     open, and, when the modifier's mix needs a guard, the mix that goes behind
     it. A bracket the colour reader has no colour for still reaches the sheet
     as written, since the custom property takes any token stream. *)
  type stop_colour = {
    tokens : Css.declaration list;
    plain : Css.color Var.channel -> Css.declaration;
    mixed : (Css.color Var.channel -> Css.declaration) option;
  }

  let plain_colour c = { tokens = []; plain = colour_decl_of c; mixed = None }

  (* Tailwind writes the colour itself at 100%, and a mix for anything else. *)
  let opaque (opacity : Color.opacity_modifier) =
    match opacity with
    | No_opacity -> true
    | Opacity_named _ | Opacity_var _ -> false
    | Opacity_percent _ | Opacity_arbitrary _ | Opacity_bracket_percent _ ->
        Color.opacity_to_percent opacity >= 100.

  (* A colour the modifier mixes, in the shape Tailwind writes it: the mix in
     the open when every browser can read it, else the colour in the open and
     the mix behind the guard. *)
  let mixed_colour ~theme (c : Css.color) opacity =
    match Color.bracket_color_opacity ~theme c opacity with
    | Folded value -> plain_colour value
    | Guarded { fallback; mixed } ->
        {
          tokens = [];
          plain = colour_decl_of fallback;
          mixed = Option.some (fun var -> set var mixed);
        }

  let colour_stop ~theme c (opacity : Color.opacity_modifier) =
    match opacity with
    | No_opacity -> plain_colour c
    | _ -> mixed_colour ~theme c opacity

  (* A palette entry at an opacity: the hex carrying the alpha in the open, the
     mix of the token behind the guard. An alpha read from a custom property has
     no percentage to fold, so the open value is the colour itself. *)
  let palette_stop ~theme c shade (opacity : Color.opacity_modifier) =
    let decl, token = Color.Handler.color_binding ~theme c shade in
    let reference : Css.color = Var token in
    if opaque opacity then
      {
        tokens = [ decl ];
        plain = (fun var -> set var reference);
        mixed = None;
      }
    else
      let value = Color.to_css ~theme c shade in
      let fallback =
        match opacity with
        | Opacity_named _ | Opacity_var _ -> value
        | _ ->
            let percent = Color.opacity_to_percent opacity in
            Color.opacity_fallback ~theme ~percent c shade value
      in
      {
        tokens = [ decl ];
        plain = (fun var -> set var fallback);
        mixed =
          Option.some (fun var -> set var (Color.mix_alpha opacity reference));
      }

  let stop_colour ~theme colour opacity =
    match colour with
    | Palette (c, shade) -> palette_stop ~theme c shade opacity
    | Keyword_color (c, _) -> colour_stop ~theme c opacity
    | Color_var name -> colour_stop ~theme (Var (Var.bracket name)) opacity
    | Bracket_color inner -> (
        match bracket_colour inner with
        | Some c -> colour_stop ~theme c opacity
        | None ->
            let text = bracket_value inner in
            {
              tokens = [];
              plain =
                (fun var ->
                  custom_property ~layer:"utilities" (Var.css_name var) text);
              mixed = None;
            })

  (* The sides a stop utility writes: both of an axis, else the one. *)
  let sides = function
    | X -> [ right_vars; left_vars ]
    | Y -> [ top_vars; bottom_vars ]
    | (Top | Right | Bottom | Left | Linear | Radial | Conic) as dir ->
        [ vars_for dir ]

  let build_stop_style ~theme dir pos_end colour opacity =
    let property_rules = property_rules_for_direction dir in
    let { tokens; plain; mixed } = stop_colour ~theme colour opacity in
    let stop v = plain (color_var v pos_end) in
    let props = tokens @ stop_decls dir stop @ composite_decls in
    match mixed with
    | None -> style ~property_rules props
    | Some mixed ->
        let guarded =
          List.map (fun v -> mixed (color_var v pos_end)) (sides dir)
        in
        style ~property_rules
          ~rules:(Some [ Color.color_mix_supports guarded ])
          props

  let to_style theme =
    let build_directional_style dir pos_end value =
      build_directional_style ~theme dir pos_end value
    in
    let build_x_style pos_end value = build_x_style ~theme pos_end value in
    let build_y_style pos_end value = build_y_style ~theme pos_end value in
    let build_linear_style pos_end value =
      build_linear_style ~theme pos_end value
    in
    let build_radial_style pos_end value =
      build_radial_style ~theme pos_end value
    in
    let build_conic_style pos_end value =
      build_conic_style ~theme pos_end value
    in
    function
    | Position (Top, pos_end, value) ->
        build_directional_style Top pos_end value
    | Position (Right, pos_end, value) ->
        build_directional_style Right pos_end value
    | Position (Bottom, pos_end, value) ->
        build_directional_style Bottom pos_end value
    | Position (Left, pos_end, value) ->
        build_directional_style Left pos_end value
    | Position (X, pos_end, value) -> build_x_style pos_end value
    | Position (Y, pos_end, value) -> build_y_style pos_end value
    | Position (Linear, pos_end, value) -> build_linear_style pos_end value
    | Position (Radial, pos_end, value) -> build_radial_style pos_end value
    | Position (Conic, pos_end, value) -> build_conic_style pos_end value
    | Linear_angle angle -> build_linear_angle_style angle
    | Conic_angle angle -> build_conic_angle_style angle
    | Radial -> build_radial_base_style
    | Radial_at pos -> build_radial_at_style pos
    | Radial_shape shape -> build_radial_shape_style shape
    | Radial_size size -> build_radial_size_style size
    | Var_ref (dir, pos_end, _, name) -> build_var_ref_style dir pos_end name
    | Stop (dir, pos_end, colour, opacity) ->
        build_stop_style ~theme dir pos_end colour opacity

  (* Tailwind sorts a layer's rules by the properties they set, walking the
     fixed table its property-order list gives: mask-image, then a block per
     side (--tw-mask-top, then its from-color, from-position, to-color and
     to-position stops, then the same for right, bottom and left), then the
     --tw-mask-linear, --tw-mask-radial and --tw-mask-conic blocks, then
     mask-composite. Two rules sort on the first table index that separates
     them, and one that runs out of properties sorts after one that keeps going.
     Every utility here writes mask-image and carries on, so they share its
     slot; inside it they sort by the index that separates them, spread four
     apart to leave room for the two tie-breaks below. *)
  let mask_image_rank = 209
  let first_stop_rank = 210

  let property_suborder index =
    Utility.Property_order.slot mask_image_rank + (4 * (index - first_stop_rank))

  (* mask-x-* and mask-y-* write the opposite side too, so they lead the
     mask-r-* and mask-t-* they share a first property with. Tailwind breaks the
     remaining ties on the class name, where the (--var) spellings lead the
     lengths and percentages. *)
  let stop_suborder dir pos_end ~color ~var =
    let block =
      match dir with
      | Top | Y -> 210
      | Right | X -> 215
      | Bottom -> 220
      | Left -> 225
      | Linear -> 231
      | Radial -> 239
      | Conic -> 245
    in
    let stop =
      match (pos_end, color) with
      | From, true -> 1
      | From, false -> 2
      | To, true -> 3
      | To, false -> 4
    in
    let sides = match dir with X | Y -> 0 | _ -> 2 in
    property_suborder (block + stop) + sides + if var then 0 else 1

  let suborder = function
    | Stop (dir, pos_end, Color_var _, _) ->
        stop_suborder dir pos_end ~color:true ~var:true
    | Stop (dir, pos_end, (Palette _ | Keyword_color _ | Bracket_color _), _) ->
        stop_suborder dir pos_end ~color:true ~var:false
    | Var_ref (dir, pos_end, _, _) ->
        stop_suborder dir pos_end ~color:false ~var:true
    | Position (dir, pos_end, _) ->
        stop_suborder dir pos_end ~color:false ~var:false
    | Linear_angle _ -> property_suborder 231
    | Radial -> property_suborder 236
    | Radial_size (Arbitrary_size _) -> property_suborder 238
    | Conic_angle _ -> property_suborder 245
    (* mask-circle, the radial size keywords and mask-radial-at-* write a
       --tw-mask-radial-* variable and no mask-image, so each closes the slot of
       the variable it names: --tw-mask-radial-shape (237),
       --tw-mask-radial-size (238) and --tw-mask-radial-position (239). The
       class name orders the ones sharing a slot. *)
    | Radial_shape _ -> Utility.Property_order.last 237
    | Radial_size _ -> Utility.Property_order.last 238
    | Radial_at _ -> Utility.Property_order.last 239

  (* Tailwind's <number>: an optional sign, digits with at most one point and a
     digit after it, and an optional exponent. It is what a percentage token
     opens with, whether or not the token then reads as a stop. *)
  let is_css_number s =
    let n = String.length s in
    let digit i = i < n && s.[i] >= '0' && s.[i] <= '9' in
    let rec digits i = if digit i then digits (i + 1) else i in
    let signed i = if i < n && (s.[i] = '+' || s.[i] = '-') then i + 1 else i in
    let i = signed 0 in
    let j = digits i in
    let j = if j < n && s.[j] = '.' then digits (j + 1) else j in
    if not (j > i && digit (j - 1)) then false
    else if j = n then true
    else if s.[j] = 'e' || s.[j] = 'E' then
      let k = signed (j + 1) in
      let l = digits k in
      l > k && l = n
    else false

  let percentage_number s =
    if String.ends_with ~suffix:"%" s then
      Option.some (String.sub s 0 (String.length s - 1))
    else Option.none

  (* A percentage stop is a whole number with no sign: 12.5%, 05% and -25% are
     percentages Tailwind refuses rather than positions it writes through. *)
  let whole_percentage number =
    match Parse.decimal_int number with
    | Some n -> n >= 0 && number.[0] <> '-'
    | None -> false

  (* A bare stop is a spacing step, read in quarters, or a whole percentage,
     neither of which takes a sign. *)
  let bare_position suffix =
    if suffix = "" || suffix.[0] = '-' then Option.none
    else
      match percentage_number suffix with
      | Some number ->
          if whole_percentage number then
            Option.some (Percent (Float.of_int (int_of_string number)))
          else Option.none
      | None -> (
          match Parse.spacing_value ~name:"mask stop" suffix with
          | Ok n -> Option.some (Spacing n)
          | Error _ -> Option.none)

  (* Tailwind's own test for a math function: one of its names followed by a
     paren anywhere in the value. *)
  let math_functions =
    [
      "calc";
      "min";
      "max";
      "clamp";
      "mod";
      "rem";
      "sin";
      "cos";
      "tan";
      "asin";
      "acos";
      "atan";
      "atan2";
      "pow";
      "sqrt";
      "hypot";
      "log";
      "exp";
      "round";
    ]

  let contains ~sub s =
    let n = String.length s and m = String.length sub in
    let rec from i = i + m <= n && (String.sub s i m = sub || from (i + 1)) in
    from 0

  let has_math_call value =
    String.contains value '('
    && List.exists (fun f -> contains ~sub:(f ^ "(") value) math_functions

  (* The colour keywords Tailwind knows: the named colours, [transparent],
     [currentcolor] and the system colours, in any case. [inherit] is not
     one. *)
  let is_colour_keyword value =
    match
      Css.Values.read_color_keyword_of_string (String.lowercase_ascii value)
    with
    | Some (Named _ | Transparent | Current | System _) -> true
    | Some _ | None -> false

  let is_colour_token value =
    (value <> "" && value.[0] = '#')
    || Parse.is_css_color_fn value
    || String.starts_with ~prefix:"--alpha(" value
    || is_colour_keyword value

  (* Which longhand a bracket stop lands in. The hint decides when the author
     wrote one; otherwise Tailwind infers: a var() or a math function it does
     not read, a percentage is one, a hex, a colour function or a colour keyword
     is the colour, and anything else is the position. *)
  type bracket_kind = Colour_stop | Percentage_stop | Position_stop

  let infer_kind value =
    if Parse.is_var value || has_math_call value then Position_stop
    else
      match percentage_number value with
      | Some number when is_css_number number -> Percentage_stop
      | Some _ | None ->
          if is_colour_token value then Colour_stop else Position_stop

  let bracket_kind hint value =
    match hint with
    | Some "color" -> Colour_stop
    | Some "percentage" -> Percentage_stop
    | Some _ -> Position_stop
    | None -> infer_kind value

  (* A bracket stop. An empty hint names no utility, and so does a value that is
     blank or would not stay inside its declaration. A colour the reader cannot
     read is written through, unless it holds a call Tailwind would have
     resolved and refused, or a modifier that has nothing to mix. A position
     takes no modifier. *)
  let bracket_stop dir pos_end opacity inner =
    let invalid = Error (`Msg ("Invalid mask stop: [" ^ inner ^ "]")) in
    let modified = opacity <> Color.No_opacity in
    match Parse.value_after_hint inner with
    | None -> invalid
    | Some value -> (
        let hint = Option.map fst (Parse.data_type_hint inner) in
        let value = Parse.decode_arbitrary_value value in
        if String.trim value = "" || not (Parse.is_declaration_value value) then
          invalid
        else
          match bracket_kind hint value with
          | Colour_stop ->
              if
                bracket_colour inner = None
                && (modified || Parse.holds_unresolved_call value)
              then invalid
              else Ok (Stop (dir, pos_end, Bracket_color inner, opacity))
          | Percentage_stop -> (
              match percentage_number value with
              | Some number when whole_percentage number && not modified ->
                  Ok (Position (dir, pos_end, Arbitrary inner))
              | Some _ | None -> invalid)
          | Position_stop ->
              if modified then invalid
              else Ok (Position (dir, pos_end, Arbitrary inner)))

  (* The (--x) shorthand. Tailwind splits a hint off at the first colon and
     takes what follows only when it names a custom property: the hint as
     written, and the name after the dashes, which the degenerate (--) leaves
     empty. *)
  let paren_var suffix =
    let n = String.length suffix in
    if n < 2 || suffix.[0] <> '(' || suffix.[n - 1] <> ')' then Option.none
    else
      let inner = String.sub suffix 1 (n - 2) in
      let hint, name =
        match Parse.data_type_hint inner with
        | Some (hint, name) -> (Option.some hint, name)
        | None -> (Option.none, inner)
      in
      if
        hint = Some ""
        || (not (String.starts_with ~prefix:"--" name))
        || not (Parse.is_declaration_value name)
      then Option.none
      else Option.some (hint, String.sub name 2 (String.length name - 2))

  (* A bare stop: Tailwind reads a colour first, [inherit], [transparent],
     [current] or a palette entry, and a step or a percentage after, which take
     no modifier. *)
  let bare_stop ~theme dir pos_end opacity base =
    let keyword c =
      Option.some (Stop (dir, pos_end, Keyword_color (c, base), opacity))
    in
    match base with
    | "transparent" -> keyword Css.Transparent
    | "current" -> keyword Css.Current
    | "inherit" -> keyword Css.Inherit
    | _ -> (
        match Color.shade_of_strings ~theme (String.split_on_char '-' base) with
        | Ok (c, shade) ->
            Option.some (Stop (dir, pos_end, Palette (c, shade), opacity))
        | Error _ when opacity <> Color.No_opacity -> Option.none
        | Error _ ->
            Option.map
              (fun v -> Position (dir, pos_end, v))
              (bare_position base))

  (* Parse directional from/to with support for values and paren refs *)
  let parse_directional ~theme dir pos_end rest =
    let base, opacity =
      Color.parse_opacity_modifier ~theme (String.concat "-" rest)
    in
    let invalid =
      Error
        (`Msg
           ("Invalid mask-" ^ direction_short dir ^ "-"
          ^ position_end_name pos_end ^ " value"))
    in
    match paren_var base with
    | Some (Some "color", name) ->
        Ok (Stop (dir, pos_end, Color_var name, opacity))
    | Some (Some "percentage", _) -> invalid
    | Some _ when opacity <> Color.No_opacity -> invalid
    | Some (hint, name) -> Ok (Var_ref (dir, pos_end, hint, name))
    | None -> (
        if Parse.is_bracket_value base then
          bracket_stop dir pos_end opacity (Parse.bracket_inner base)
        else
          match bare_stop ~theme dir pos_end opacity base with
          | Some stop -> Ok stop
          | None -> invalid)

  let of_class theme class_name =
    let parts = Parse.split_class class_name in
    match parts with
    (* mask-t-from-*, mask-t-to-* *)
    | "mask" :: "t" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme Top From rest
    | "mask" :: "t" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme Top To rest
    (* mask-r-from-*, mask-r-to-* *)
    | "mask" :: "r" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme Right From rest
    | "mask" :: "r" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme Right To rest
    (* mask-b-from-*, mask-b-to-* *)
    | "mask" :: "b" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme Bottom From rest
    | "mask" :: "b" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme Bottom To rest
    (* mask-l-from-*, mask-l-to-* *)
    | "mask" :: "l" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme Left From rest
    | "mask" :: "l" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme Left To rest
    (* mask-x-from-*, mask-x-to-* *)
    | "mask" :: "x" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme X From rest
    | "mask" :: "x" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme X To rest
    (* mask-y-from-*, mask-y-to-* *)
    | "mask" :: "y" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme Y From rest
    | "mask" :: "y" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme Y To rest
    (* mask-linear-from-*, mask-linear-to-* *)
    | "mask" :: "linear" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme Linear From rest
    | "mask" :: "linear" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme Linear To rest
    (* mask-linear-N (angle), mask-linear-[arb] *)
    | [ "mask"; "linear"; n ] -> (
        if Parse.is_bracket_value n then
          let inner = Parse.bracket_inner n in
          Ok (Linear_angle (Arb inner))
        else
          match Parse.decimal_int n with
          | Some i -> Ok (Linear_angle (Int i))
          | None -> Error (`Msg "Invalid mask-linear angle value"))
    (* -mask-linear-N (negative angle), -mask-linear-[arb] *)
    | [ ""; "mask"; "linear"; n ] -> (
        if Parse.is_bracket_value n then
          let inner = Parse.bracket_inner n in
          Ok (Linear_angle (Arb_neg inner))
        else
          match Parse.decimal_int n with
          | Some i -> Ok (Linear_angle (Int (-i)))
          | None -> Error (`Msg "Invalid negative mask-linear angle value"))
    (* mask-radial *)
    | [ "mask"; "radial" ] -> Ok Radial
    (* mask-radial-at-* *)
    | "mask" :: "radial" :: "at" :: rest when rest <> [] ->
        let position = String.concat " " rest in
        (* Handle arbitrary values - strip brackets *)
        if Parse.is_bracket_value position then
          let inner = Parse.bracket_inner position in
          (* The channel is this family's longhand, so a bracket no position
             reader took still reaches it; [build_radial_at_style] already
             writes such a bracket verbatim. *)
          if
            radial_at_position inner = None
            && Parse.arbitrary_declaration_value inner = None
          then Error (`Msg ("Invalid mask-radial-at position: " ^ position))
          else Ok (Radial_at (Bracket inner))
        else
          (* Validate keyword positions: top, bottom, left, right, center and
             combinations *)
          let is_valid_keyword =
            List.for_all
              (fun w ->
                List.mem w [ "top"; "bottom"; "left"; "right"; "center" ])
              rest
          in
          if is_valid_keyword then Ok (Radial_at (Keyword position))
          else Error (`Msg ("Invalid mask-radial-at position: " ^ position))
    (* mask-radial-from-*, mask-radial-to-* *)
    | "mask" :: "radial" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme Radial From rest
    | "mask" :: "radial" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme Radial To rest
    (* mask-conic-from-*, mask-conic-to-* *)
    | "mask" :: "conic" :: "from" :: rest when rest <> [] ->
        parse_directional ~theme Conic From rest
    | "mask" :: "conic" :: "to" :: rest when rest <> [] ->
        parse_directional ~theme Conic To rest
    (* mask-conic-N (angle), mask-conic-[arb] *)
    | [ "mask"; "conic"; n ] -> (
        if Parse.is_bracket_value n then
          let inner = Parse.bracket_inner n in
          Ok (Conic_angle (Arb inner))
        else
          match Parse.decimal_int n with
          | Some i -> Ok (Conic_angle (Int i))
          | None -> Error (`Msg "Invalid mask-conic angle value"))
    (* -mask-conic-N (negative angle), -mask-conic-[arb] *)
    | [ ""; "mask"; "conic"; n ] -> (
        if Parse.is_bracket_value n then
          let inner = Parse.bracket_inner n in
          Ok (Conic_angle (Arb_neg inner))
        else
          match Parse.decimal_int n with
          | Some i -> Ok (Conic_angle (Int (-i)))
          | None -> Error (`Msg "Invalid negative mask-conic angle value"))
    (* mask-circle, mask-ellipse *)
    | [ "mask"; "circle" ] -> Ok (Radial_shape Circle)
    | [ "mask"; "ellipse" ] -> Ok (Radial_shape Ellipse)
    (* mask-radial size keywords *)
    | [ "mask"; "radial"; "closest"; "corner" ] ->
        Ok (Radial_size Closest_corner)
    | [ "mask"; "radial"; "closest"; "side" ] -> Ok (Radial_size Closest_side)
    | [ "mask"; "radial"; "farthest"; "corner" ] ->
        Ok (Radial_size Farthest_corner)
    | [ "mask"; "radial"; "farthest"; "side" ] -> Ok (Radial_size Farthest_side)
    (* mask-radial-[size] - arbitrary size *)
    | [ "mask"; "radial"; arb ] when Parse.is_bracket_value arb ->
        let size_value = Parse.bracket_inner arb in
        let size_value = Parse.decode_underscores size_value in
        if not (Parse.is_declaration_value size_value) then
          Error (`Msg ("Invalid mask-radial size: " ^ size_value))
        else Ok (Radial_size (Arbitrary_size size_value))
    | _ -> Error (`Msg "Not a mask gradient utility")

  let format_value = function
    | Spacing n ->
        if Float.is_integer n then string_of_int (int_of_float n)
        else string_of_float n
    | Percent p ->
        if Float.is_integer p then pp_int (int_of_float p) ^ "%"
        else pp_float p ^ "%"
    | Arbitrary v -> "[" ^ v ^ "]"

  let stop_color_class = function
    | Palette (c, shade) ->
        Color.color_to_string c
        ^ if Color.is_shadeless c then "" else "-" ^ string_of_int shade
    | Keyword_color (_, name) -> name
    | Bracket_color inner -> "[" ^ inner ^ "]"
    | Color_var name -> "(color:--" ^ name ^ ")"

  let to_class = function
    | Position (dir, pos_end, value) ->
        "mask-" ^ direction_short dir ^ "-" ^ position_end_name pos_end ^ "-"
        ^ format_value value
    | Var_ref (dir, pos_end, hint, name) ->
        let hint = match hint with Some h -> h ^ ":" | None -> "" in
        "mask-" ^ direction_short dir ^ "-" ^ position_end_name pos_end ^ "-("
        ^ hint ^ "--" ^ name ^ ")"
    | Stop (dir, pos_end, colour, opacity) ->
        "mask-" ^ direction_short dir ^ "-" ^ position_end_name pos_end ^ "-"
        ^ stop_color_class colour
        ^ Color.opacity_suffix opacity
    | Linear_angle (Int n) ->
        if n < 0 then "-mask-linear-" ^ string_of_int (-n)
        else "mask-linear-" ^ string_of_int n
    | Linear_angle (Arb s) -> "mask-linear-[" ^ s ^ "]"
    | Linear_angle (Arb_neg s) -> "-mask-linear-[" ^ s ^ "]"
    | Conic_angle (Int n) ->
        if n < 0 then "-mask-conic-" ^ string_of_int (-n)
        else "mask-conic-" ^ string_of_int n
    | Conic_angle (Arb s) -> "mask-conic-[" ^ s ^ "]"
    | Conic_angle (Arb_neg s) -> "-mask-conic-[" ^ s ^ "]"
    | Radial -> "mask-radial"
    | Radial_at (Keyword pos) ->
        "mask-radial-at-" ^ String.concat "-" (String.split_on_char ' ' pos)
    | Radial_at (Bracket pos) -> "mask-radial-at-[" ^ pos ^ "]"
    | Radial_shape Circle -> "mask-circle"
    | Radial_shape Ellipse -> "mask-ellipse"
    | Radial_size Closest_corner -> "mask-radial-closest-corner"
    | Radial_size Closest_side -> "mask-radial-closest-side"
    | Radial_size Farthest_corner -> "mask-radial-farthest-corner"
    | Radial_size Farthest_side -> "mask-radial-farthest-side"
    | Radial_size (Arbitrary_size s) ->
        (* The size is held decoded, so the bracket it goes back into spells a
           space [_] and an underscore [\_]. *)
        "mask-radial-[" ^ Parse.encode_underscores s ^ "]"

  let examples = [ Linear_angle (Int 0) ]
end

open Handler
module Utility_factory = Utility.Make (Handler)

let utility = Utility_factory.v

(* Convenience functions for creating mask gradient utilities *)
let mask_t_from value = utility (Position (Top, From, value))
let mask_t_to value = utility (Position (Top, To, value))
let mask_r_from value = utility (Position (Right, From, value))
let mask_r_to value = utility (Position (Right, To, value))
let mask_b_from value = utility (Position (Bottom, From, value))
let mask_b_to value = utility (Position (Bottom, To, value))
let mask_l_from value = utility (Position (Left, From, value))
let mask_l_to value = utility (Position (Left, To, value))
let mask_x_from value = utility (Position (X, From, value))
let mask_x_to value = utility (Position (X, To, value))
let mask_y_from value = utility (Position (Y, From, value))
let mask_y_to value = utility (Position (Y, To, value))
let mask_linear_from value = utility (Position (Linear, From, value))
let mask_linear_to value = utility (Position (Linear, To, value))
let mask_radial = utility Radial
let mask_radial_at pos = utility (Radial_at pos)
let mask_radial_from value = utility (Position (Radial, From, value))
let mask_radial_to value = utility (Position (Radial, To, value))
let mask_conic_from value = utility (Position (Conic, From, value))
let mask_conic_to value = utility (Position (Conic, To, value))