diff --git a/experimental/postcss-gradient-stop-increments/.gitignore b/experimental/postcss-gradient-stop-increments/.gitignore
new file mode 100644
index 000000000..e5b28db4a
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+package-lock.json
+yarn.lock
+*.result.css
+*.result.css.map
+*.result.html
diff --git a/experimental/postcss-gradient-stop-increments/.nvmrc b/experimental/postcss-gradient-stop-increments/.nvmrc
new file mode 100644
index 000000000..39e593ebe
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/.nvmrc
@@ -0,0 +1 @@
+v18.8.0
diff --git a/experimental/postcss-gradient-stop-increments/.tape.mjs b/experimental/postcss-gradient-stop-increments/.tape.mjs
new file mode 100644
index 000000000..fa441888d
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/.tape.mjs
@@ -0,0 +1,17 @@
+import { postcssTape } from '@csstools/postcss-tape';
+import plugin from '@csstools/postcss-gradient-stop-increments-experimental';
+
+postcssTape(plugin)({
+ basic: {
+ message: "supports basic usage",
+ },
+ 'examples/example': {
+ message: 'minimal example',
+ },
+ 'examples/example:preserve-true': {
+ message: 'minimal example',
+ options: {
+ preserve: true
+ }
+ },
+});
diff --git a/experimental/postcss-gradient-stop-increments/CHANGELOG.md b/experimental/postcss-gradient-stop-increments/CHANGELOG.md
new file mode 100644
index 000000000..f3f666aa9
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changes to PostCSS Gradient Stop Increments
+
+### Unreleased (major)
+
+- Initial version
diff --git a/experimental/postcss-gradient-stop-increments/INSTALL.md b/experimental/postcss-gradient-stop-increments/INSTALL.md
new file mode 100644
index 000000000..e08a34f16
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/INSTALL.md
@@ -0,0 +1,235 @@
+# Installing PostCSS Gradient Stop Increments
+
+[PostCSS Gradient Stop Increments] runs in all Node environments, with special instructions for:
+
+- [Node](#node)
+- [PostCSS CLI](#postcss-cli)
+- [PostCSS Load Config](#postcss-load-config)
+- [Webpack](#webpack)
+- [Next.js](#nextjs)
+- [Gulp](#gulp)
+- [Grunt](#grunt)
+
+
+
+## Node
+
+Add [PostCSS Gradient Stop Increments] to your project:
+
+```bash
+npm install postcss @csstools/postcss-gradient-stop-increments-experimental --save-dev
+```
+
+Use it as a [PostCSS] plugin:
+
+```js
+// commonjs
+const postcss = require('postcss');
+const postcssGradientStopIncrementsExperimental = require('@csstools/postcss-gradient-stop-increments-experimental');
+
+postcss([
+ postcssGradientStopIncrementsExperimental(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+```js
+// esm
+import postcss from 'postcss';
+import postcssGradientStopIncrementsExperimental from '@csstools/postcss-gradient-stop-increments-experimental';
+
+postcss([
+ postcssGradientStopIncrementsExperimental(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+## PostCSS CLI
+
+Add [PostCSS CLI] to your project:
+
+```bash
+npm install postcss-cli @csstools/postcss-gradient-stop-increments-experimental --save-dev
+```
+
+Use [PostCSS Gradient Stop Increments] in your `postcss.config.js` configuration file:
+
+```js
+const postcssGradientStopIncrementsExperimental = require('@csstools/postcss-gradient-stop-increments-experimental');
+
+module.exports = {
+ plugins: [
+ postcssGradientStopIncrementsExperimental(/* pluginOptions */)
+ ]
+}
+```
+
+## PostCSS Load Config
+
+If your framework/CLI supports [`postcss-load-config`](https://github.com/postcss/postcss-load-config).
+
+```bash
+npm install @csstools/postcss-gradient-stop-increments-experimental --save-dev
+```
+
+`package.json`:
+
+```json
+{
+ "postcss": {
+ "plugins": {
+ "@csstools/postcss-gradient-stop-increments-experimental": {}
+ }
+ }
+}
+```
+
+`.postcssrc.json`:
+
+```json
+{
+ "plugins": {
+ "@csstools/postcss-gradient-stop-increments-experimental": {}
+ }
+}
+```
+
+_See the [README of `postcss-load-config`](https://github.com/postcss/postcss-load-config#usage) for more usage options._
+
+## Webpack
+
+_Webpack version 5_
+
+Add [PostCSS Loader] to your project:
+
+```bash
+npm install postcss-loader @csstools/postcss-gradient-stop-increments-experimental --save-dev
+```
+
+Use [PostCSS Gradient Stop Increments] in your Webpack configuration:
+
+```js
+module.exports = {
+ module: {
+ rules: [
+ {
+ test: /\.css$/i,
+ use: [
+ "style-loader",
+ {
+ loader: "css-loader",
+ options: { importLoaders: 1 },
+ },
+ {
+ loader: "postcss-loader",
+ options: {
+ postcssOptions: {
+ plugins: [
+ // Other plugins,
+ [
+ "@csstools/postcss-gradient-stop-increments-experimental",
+ {
+ // Options
+ },
+ ],
+ ],
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+};
+```
+
+## Next.js
+
+Read the instructions on how to [customize the PostCSS configuration in Next.js](https://nextjs.org/docs/advanced-features/customizing-postcss-config)
+
+```bash
+npm install @csstools/postcss-gradient-stop-increments-experimental --save-dev
+```
+
+Use [PostCSS Gradient Stop Increments] in your `postcss.config.json` file:
+
+```json
+{
+ "plugins": [
+ "@csstools/postcss-gradient-stop-increments-experimental"
+ ]
+}
+```
+
+```json5
+{
+ "plugins": [
+ [
+ "@csstools/postcss-gradient-stop-increments-experimental",
+ {
+ // Optionally add plugin options
+ }
+ ]
+ ]
+}
+```
+
+## Gulp
+
+Add [Gulp PostCSS] to your project:
+
+```bash
+npm install gulp-postcss @csstools/postcss-gradient-stop-increments-experimental --save-dev
+```
+
+Use [PostCSS Gradient Stop Increments] in your Gulpfile:
+
+```js
+const postcss = require('gulp-postcss');
+const postcssGradientStopIncrementsExperimental = require('@csstools/postcss-gradient-stop-increments-experimental');
+
+gulp.task('css', function () {
+ var plugins = [
+ postcssGradientStopIncrementsExperimental(/* pluginOptions */)
+ ];
+
+ return gulp.src('./src/*.css')
+ .pipe(postcss(plugins))
+ .pipe(gulp.dest('.'));
+});
+```
+
+## Grunt
+
+Add [Grunt PostCSS] to your project:
+
+```bash
+npm install grunt-postcss @csstools/postcss-gradient-stop-increments-experimental --save-dev
+```
+
+Use [PostCSS Gradient Stop Increments] in your Gruntfile:
+
+```js
+const postcssGradientStopIncrementsExperimental = require('@csstools/postcss-gradient-stop-increments-experimental');
+
+grunt.loadNpmTasks('grunt-postcss');
+
+grunt.initConfig({
+ postcss: {
+ options: {
+ processors: [
+ postcssGradientStopIncrementsExperimental(/* pluginOptions */)
+ ]
+ },
+ dist: {
+ src: '*.css'
+ }
+ }
+});
+```
+
+[Gulp PostCSS]: https://github.com/postcss/gulp-postcss
+[Grunt PostCSS]: https://github.com/nDmitry/grunt-postcss
+[PostCSS]: https://github.com/postcss/postcss
+[PostCSS CLI]: https://github.com/postcss/postcss-cli
+[PostCSS Loader]: https://github.com/postcss/postcss-loader
+[PostCSS Gradient Stop Increments]: https://github.com/csstools/postcss-plugins/tree/main/experimental/postcss-gradient-stop-increments
+[Next.js]: https://nextjs.org
diff --git a/experimental/postcss-gradient-stop-increments/LICENSE.md b/experimental/postcss-gradient-stop-increments/LICENSE.md
new file mode 100644
index 000000000..0bc1fa706
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/LICENSE.md
@@ -0,0 +1,108 @@
+# CC0 1.0 Universal
+
+## Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator and
+subsequent owner(s) (each and all, an “owner”) of an original work of
+authorship and/or a database (each, a “Work”).
+
+Certain owners wish to permanently relinquish those rights to a Work for the
+purpose of contributing to a commons of creative, cultural and scientific works
+(“Commons”) that the public can reliably and without fear of later claims of
+infringement build upon, modify, incorporate in other works, reuse and
+redistribute as freely as possible in any form whatsoever and for any purposes,
+including without limitation commercial purposes. These owners may contribute
+to the Commons to promote the ideal of a free culture and the further
+production of creative, cultural and scientific works, or to gain reputation or
+greater distribution for their Work in part through the use and efforts of
+others.
+
+For these and/or other purposes and motivations, and without any expectation of
+additional consideration or compensation, the person associating CC0 with a
+Work (the “Affirmer”), to the extent that he or she is an owner of Copyright
+and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and
+publicly distribute the Work under its terms, with knowledge of his or her
+Copyright and Related Rights in the Work and the meaning and intended legal
+effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+ protected by copyright and related or neighboring rights (“Copyright and
+ Related Rights”). Copyright and Related Rights include, but are not limited
+ to, the following:
+ 1. the right to reproduce, adapt, distribute, perform, display, communicate,
+ and translate a Work;
+ 2. moral rights retained by the original author(s) and/or performer(s);
+ 3. publicity and privacy rights pertaining to a person’s image or likeness
+ depicted in a Work;
+ 4. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(i), below;
+ 5. rights protecting the extraction, dissemination, use and reuse of data in
+ a Work;
+ 6. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation thereof,
+ including any amended or successor version of such directive); and
+ 7. other similar, equivalent or corresponding rights throughout the world
+ based on applicable law or treaty, and any national implementations
+ thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention of,
+ applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
+ unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright
+ and Related Rights and associated claims and causes of action, whether now
+ known or unknown (including existing as well as future claims and causes of
+ action), in the Work (i) in all territories worldwide, (ii) for the maximum
+ duration provided by applicable law or treaty (including future time
+ extensions), (iii) in any current or future medium and for any number of
+ copies, and (iv) for any purpose whatsoever, including without limitation
+ commercial, advertising or promotional purposes (the “Waiver”). Affirmer
+ makes the Waiver for the benefit of each member of the public at large and
+ to the detriment of Affirmer’s heirs and successors, fully intending that
+ such Waiver shall not be subject to revocation, rescission, cancellation,
+ termination, or any other legal or equitable action to disrupt the quiet
+ enjoyment of the Work by the public as contemplated by Affirmer’s express
+ Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason be
+ judged legally invalid or ineffective under applicable law, then the Waiver
+ shall be preserved to the maximum extent permitted taking into account
+ Affirmer’s express Statement of Purpose. In addition, to the extent the
+ Waiver is so judged Affirmer hereby grants to each affected person a
+ royalty-free, non transferable, non sublicensable, non exclusive,
+ irrevocable and unconditional license to exercise Affirmer’s Copyright and
+ Related Rights in the Work (i) in all territories worldwide, (ii) for the
+ maximum duration provided by applicable law or treaty (including future time
+ extensions), (iii) in any current or future medium and for any number of
+ copies, and (iv) for any purpose whatsoever, including without limitation
+ commercial, advertising or promotional purposes (the “License”). The License
+ shall be deemed effective as of the date CC0 was applied by Affirmer to the
+ Work. Should any part of the License for any reason be judged legally
+ invalid or ineffective under applicable law, such partial invalidity or
+ ineffectiveness shall not invalidate the remainder of the License, and in
+ such case Affirmer hereby affirms that he or she will not (i) exercise any
+ of his or her remaining Copyright and Related Rights in the Work or (ii)
+ assert any associated claims and causes of action with respect to the Work,
+ in either case contrary to Affirmer’s express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+ 1. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ 2. Affirmer offers the Work as-is and makes no representations or warranties
+ of any kind concerning the Work, express, implied, statutory or
+ otherwise, including without limitation warranties of title,
+ merchantability, fitness for a particular purpose, non infringement, or
+ the absence of latent or other defects, accuracy, or the present or
+ absence of errors, whether or not discoverable, all to the greatest
+ extent permissible under applicable law.
+ 3. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person’s Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the Work.
+ 4. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to this
+ CC0 or use of the Work.
+
+For more information, please see
+http://creativecommons.org/publicdomain/zero/1.0/.
diff --git a/experimental/postcss-gradient-stop-increments/README.md b/experimental/postcss-gradient-stop-increments/README.md
new file mode 100644
index 000000000..bfc4d4a21
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/README.md
@@ -0,0 +1,131 @@
+# PostCSS Gradient Stop Increments [
][PostCSS]
+
+[
][npm-url] [
][cli-url] [
][discord]
+
+[PostCSS Gradient Stop Increments] lets you increment gradient stops following the [CSSWG 8616 proposal].
+
+```pcss
+.example-1 {
+ background: linear-gradient(red 50%, blue ++1px);
+}
+
+.example-2 {
+ background: conic-gradient(red ++60deg, blue ++10deg ++50deg, green ++60deg, yellow ++60deg);
+}
+
+.example-3 {
+ background: conic-gradient(pink ++60deg, cyan 0 ++20deg, gold 0 ++10deg);
+}
+
+.example-4 {
+ background: linear-gradient(orange 20px, magenta 2vi, aqua ++2vw);
+}
+
+/* becomes */
+
+.example-1 {
+ background: linear-gradient(red 50%, blue calc(50% + +1px));
+}
+
+.example-2 {
+ background: conic-gradient(red +60deg, blue 70deg 120deg, green 180deg, yellow 240deg);
+}
+
+.example-3 {
+ background: conic-gradient(pink +60deg, cyan 0 80deg, gold 0 90deg);
+}
+
+.example-4 {
+ background: linear-gradient(orange 20px, magenta 2vi, aqua calc(max(20px, 2vi) + +2vw));
+}
+```
+
+## Usage
+
+Add [PostCSS Gradient Stop Increments] to your project:
+
+```bash
+npm install postcss @csstools/postcss-gradient-stop-increments-experimental --save-dev
+```
+
+Use it as a [PostCSS] plugin:
+
+```js
+const postcss = require('postcss');
+const postcssGradientStopIncrementsExperimental = require('@csstools/postcss-gradient-stop-increments-experimental');
+
+postcss([
+ postcssGradientStopIncrementsExperimental(/* pluginOptions */)
+]).process(YOUR_CSS /*, processOptions */);
+```
+
+[PostCSS Gradient Stop Increments] runs in all Node environments, with special
+instructions for:
+
+- [Node](INSTALL.md#node)
+- [PostCSS CLI](INSTALL.md#postcss-cli)
+- [PostCSS Load Config](INSTALL.md#postcss-load-config)
+- [Webpack](INSTALL.md#webpack)
+- [Next.js](INSTALL.md#nextjs)
+- [Gulp](INSTALL.md#gulp)
+- [Grunt](INSTALL.md#grunt)
+
+## Options
+
+### preserve
+
+The `preserve` option determines whether the original notation
+is preserved. By default, it is not preserved.
+
+```js
+postcssGradientStopIncrementsExperimental({ preserve: true })
+```
+
+```pcss
+.example-1 {
+ background: linear-gradient(red 50%, blue ++1px);
+}
+
+.example-2 {
+ background: conic-gradient(red ++60deg, blue ++10deg ++50deg, green ++60deg, yellow ++60deg);
+}
+
+.example-3 {
+ background: conic-gradient(pink ++60deg, cyan 0 ++20deg, gold 0 ++10deg);
+}
+
+.example-4 {
+ background: linear-gradient(orange 20px, magenta 2vi, aqua ++2vw);
+}
+
+/* becomes */
+
+.example-1 {
+ background: linear-gradient(red 50%, blue calc(50% + +1px));
+ background: linear-gradient(red 50%, blue ++1px);
+}
+
+.example-2 {
+ background: conic-gradient(red +60deg, blue 70deg 120deg, green 180deg, yellow 240deg);
+ background: conic-gradient(red ++60deg, blue ++10deg ++50deg, green ++60deg, yellow ++60deg);
+}
+
+.example-3 {
+ background: conic-gradient(pink +60deg, cyan 0 80deg, gold 0 90deg);
+ background: conic-gradient(pink ++60deg, cyan 0 ++20deg, gold 0 ++10deg);
+}
+
+.example-4 {
+ background: linear-gradient(orange 20px, magenta 2vi, aqua calc(max(20px, 2vi) + +2vw));
+ background: linear-gradient(orange 20px, magenta 2vi, aqua ++2vw);
+}
+```
+
+[cli-url]: https://github.com/csstools/postcss-plugins/actions/workflows/test.yml?query=workflow/test
+
+[discord]: https://discord.gg/bUadyRwkJS
+[npm-url]: https://www.npmjs.com/package/@csstools/postcss-gradient-stop-increments-experimental
+
+[PostCSS]: https://github.com/postcss/postcss
+[PostCSS Gradient Stop Increments]: https://github.com/csstools/postcss-plugins/tree/main/experimental/postcss-gradient-stop-increments
+[CSSWG 8616 proposal]: https://github.com/w3c/csswg-drafts/issues/8616
diff --git a/experimental/postcss-gradient-stop-increments/dist/index.cjs b/experimental/postcss-gradient-stop-increments/dist/index.cjs
new file mode 100644
index 000000000..765d88a0d
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/dist/index.cjs
@@ -0,0 +1 @@
+"use strict";var e=require("@csstools/css-parser-algorithms"),n=require("@csstools/css-calc"),o=require("@csstools/css-tokenizer");const t=/(repeating-)?(linear|radial|conic)-gradient\(/i,a=/^(repeating-)?(linear|radial|conic)-gradient$/i,i=/^(abs|acos|asin|atan|atan2|calc|clamp|cos|exp|hypot|log|max|min|mod|pow|rem|round|sign|sin|sqrt|tan)$/i,creator=n=>{const s=Object.assign({preserve:!1},n);return{postcssPlugin:"postcss-gradient-stop-increments",Declaration(n){if(!t.test(n.value))return;if(!o.tokenize({css:n.value}).find((e=>e[0]===o.TokenType.Delim&&"+"===e[4].value)))return;const r=replaceComponentValues(e.parseCommaSeparatedListOfComponentValues(o.tokenize({css:n.value})),(n=>{if(!e.isFunctionNode(n))return;const t=n.getName();if(!a.test(t))return;let s=null;for(let t=0;te.map((e=>o.stringify(...e.tokens()))).join(""))).join(",");r!==n.value&&(n.cloneBefore({value:r}),null!=s&&s.preserve||n.remove())}}};function isNumericLargerThanZero(n){return!(!e.isTokenNode(n)||n.value[0]!==o.TokenType.Percentage&&n.value[0]!==o.TokenType.Dimension&&n.value[0]!==o.TokenType.Number||!(n.value[4].value>0))}function isZeroOrNegative(n){return!(!e.isTokenNode(n)||n.value[0]!==o.TokenType.Percentage&&n.value[0]!==o.TokenType.Dimension&&n.value[0]!==o.TokenType.Number||!(n.value[4].value<=0))}function incrementLengthNode(t,a,i){if(!t)return i;const s=new e.FunctionNode([o.TokenType.Function,"calc(",-1,-1,{value:"calc"}],[o.TokenType.CloseParen,")",-1,-1,void 0],[t,new e.WhitespaceNode([[o.TokenType.Whitespace," ",-1,-1,void 0]]),a,new e.WhitespaceNode([[o.TokenType.Whitespace," ",-1,-1,void 0]]),i]),[[r]]=n.calcFromComponentValues([[s]]);return r}function maxOfLastAndCurrentLengthNode(t,a){if(!t)return a;const i=new e.FunctionNode([o.TokenType.Function,"max(",-1,-1,{value:"max"}],[o.TokenType.CloseParen,")",-1,-1,void 0],[t,new e.TokenNode([o.TokenType.Comma,",",-1,-1,void 0]),new e.WhitespaceNode([[o.TokenType.Whitespace," ",-1,-1,void 0]]),a]),[[s]]=n.calcFromComponentValues([[i]]);return s}function replaceComponentValues(n,o){for(let t=0;t{if("number"!=typeof n)return;const t=e.node,a=o(t);a&&e.parent.value.splice(n,1,a)}))}}return n}creator.postcss=!0,module.exports=creator;
diff --git a/experimental/postcss-gradient-stop-increments/dist/index.d.ts b/experimental/postcss-gradient-stop-increments/dist/index.d.ts
new file mode 100644
index 000000000..8547e0f53
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/dist/index.d.ts
@@ -0,0 +1,8 @@
+import type { PluginCreator } from 'postcss';
+/** postcss-gradient-stop-increments plugin options */
+export type pluginOptions = {
+ /** Preserve the original notation. default: false */
+ preserve?: boolean;
+};
+declare const creator: PluginCreator;
+export default creator;
diff --git a/experimental/postcss-gradient-stop-increments/dist/index.mjs b/experimental/postcss-gradient-stop-increments/dist/index.mjs
new file mode 100644
index 000000000..eef16da89
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/dist/index.mjs
@@ -0,0 +1 @@
+import{parseCommaSeparatedListOfComponentValues as e,isSimpleBlockNode as n,isFunctionNode as t,isTokenNode as a,isCommentNode as r,TokenNode as o,FunctionNode as s,WhitespaceNode as i}from"@csstools/css-parser-algorithms";import{calcFromComponentValues as l}from"@csstools/css-calc";import{tokenize as u,TokenType as c,stringify as v,NumberType as m}from"@csstools/css-tokenizer";const p=/(repeating-)?(linear|radial|conic)-gradient\(/i,f=/^(repeating-)?(linear|radial|conic)-gradient$/i,g=/^(abs|acos|asin|atan|atan2|calc|clamp|cos|exp|hypot|log|max|min|mod|pow|rem|round|sign|sin|sqrt|tan)$/i,creator=n=>{const s=Object.assign({preserve:!1},n);return{postcssPlugin:"postcss-gradient-stop-increments",Declaration(n){if(!p.test(n.value))return;if(!u({css:n.value}).find((e=>e[0]===c.Delim&&"+"===e[4].value)))return;const i=replaceComponentValues(e(u({css:n.value})),(e=>{if(!t(e))return;const n=e.getName();if(!f.test(n))return;let s=null;for(let n=0;ne.map((e=>v(...e.tokens()))).join(""))).join(",");i!==n.value&&(n.cloneBefore({value:i}),null!=s&&s.preserve||n.remove())}}};function isNumericLargerThanZero(e){return!(!a(e)||e.value[0]!==c.Percentage&&e.value[0]!==c.Dimension&&e.value[0]!==c.Number||!(e.value[4].value>0))}function isZeroOrNegative(e){return!(!a(e)||e.value[0]!==c.Percentage&&e.value[0]!==c.Dimension&&e.value[0]!==c.Number||!(e.value[4].value<=0))}function incrementLengthNode(e,n,t){if(!e)return t;const a=new s([c.Function,"calc(",-1,-1,{value:"calc"}],[c.CloseParen,")",-1,-1,void 0],[e,new i([[c.Whitespace," ",-1,-1,void 0]]),n,new i([[c.Whitespace," ",-1,-1,void 0]]),t]),[[r]]=l([[a]]);return r}function maxOfLastAndCurrentLengthNode(e,n){if(!e)return n;const t=new s([c.Function,"max(",-1,-1,{value:"max"}],[c.CloseParen,")",-1,-1,void 0],[e,new o([c.Comma,",",-1,-1,void 0]),new i([[c.Whitespace," ",-1,-1,void 0]]),n]),[[a]]=l([[t]]);return a}function replaceComponentValues(e,a){for(let r=0;r{if("number"!=typeof n)return;const t=e.node,r=a(t);r&&e.parent.value.splice(n,1,r)}))}}return e}creator.postcss=!0;export{creator as default};
diff --git a/experimental/postcss-gradient-stop-increments/docs/README.md b/experimental/postcss-gradient-stop-increments/docs/README.md
new file mode 100644
index 000000000..693904d95
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/docs/README.md
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+[] lets you increment gradient stops following the [CSSWG 8616 proposal].
+
+```pcss
+
+
+/* becomes */
+
+
+```
+
+
+
+
+
+## Options
+
+### preserve
+
+The `preserve` option determines whether the original notation
+is preserved. By default, it is not preserved.
+
+```js
+({ preserve: true })
+```
+
+```pcss
+
+
+/* becomes */
+
+
+```
+
+
+[CSSWG 8616 proposal]:
diff --git a/experimental/postcss-gradient-stop-increments/package.json b/experimental/postcss-gradient-stop-increments/package.json
new file mode 100644
index 000000000..1fd847e48
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/package.json
@@ -0,0 +1,78 @@
+{
+ "name": "@csstools/postcss-gradient-stop-increments-experimental",
+ "description": "Increment CSS gradient stops",
+ "version": "0.0.0",
+ "contributors": [
+ {
+ "name": "Antonio Laguna",
+ "email": "antonio@laguna.es",
+ "url": "https://antonio.laguna.es"
+ },
+ {
+ "name": "Romain Menke",
+ "email": "romainmenke@gmail.com"
+ }
+ ],
+ "license": "CC0-1.0",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ },
+ "engines": {
+ "node": "^14 || ^16 || >=18"
+ },
+ "main": "dist/index.cjs",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.cjs",
+ "default": "./dist/index.mjs"
+ }
+ },
+ "files": [
+ "CHANGELOG.md",
+ "LICENSE.md",
+ "README.md",
+ "dist"
+ ],
+ "dependencies": {
+ "@csstools/css-calc": "1.0.0",
+ "@csstools/css-parser-algorithms": "2.0.1",
+ "@csstools/css-tokenizer": "^2.1.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4"
+ },
+ "devDependencies": {
+ "@csstools/postcss-tape": "*"
+ },
+ "scripts": {
+ "build": "rollup -c ../../rollup/default.mjs",
+ "docs": "node ../../.github/bin/generate-docs/install.mjs && node ../../.github/bin/generate-docs/readme.mjs",
+ "lint": "node ../../.github/bin/format-package-json.mjs",
+ "prepublishOnly": "npm run build && npm run test",
+ "test": "node .tape.mjs && node ./test/_import.mjs && node ./test/_require.cjs",
+ "test:rewrite-expects": "REWRITE_EXPECTS=true node .tape.mjs"
+ },
+ "homepage": "https://github.com/csstools/postcss-plugins/tree/main/plugins/postcss-gradient-stop-increments#readme",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/csstools/postcss-plugins.git",
+ "directory": "plugins/postcss-gradient-stop-increments"
+ },
+ "bugs": "https://github.com/csstools/postcss-plugins/issues",
+ "keywords": [
+ "postcss-plugin"
+ ],
+ "csstools": {
+ "exportName": "postcssGradientStopIncrementsExperimental",
+ "humanReadableName": "PostCSS Gradient Stop Increments",
+ "specUrl": "https://github.com/w3c/csswg-drafts/issues/8616"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/experimental/postcss-gradient-stop-increments/src/index.ts b/experimental/postcss-gradient-stop-increments/src/index.ts
new file mode 100644
index 000000000..9d741343c
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/src/index.ts
@@ -0,0 +1,248 @@
+import { ComponentValue, TokenNode } from '@csstools/css-parser-algorithms';
+import type { PluginCreator } from 'postcss';
+import { FunctionNode, isCommentNode, isTokenNode, WhitespaceNode, isFunctionNode, isSimpleBlockNode, parseCommaSeparatedListOfComponentValues } from '@csstools/css-parser-algorithms';
+import { calcFromComponentValues } from '@csstools/css-calc';
+import { NumberType, stringify as stringifyTokens } from '@csstools/css-tokenizer';
+import { tokenize, TokenType } from '@csstools/css-tokenizer';
+
+/** postcss-gradient-stop-increments plugin options */
+export type pluginOptions = {
+ /** Preserve the original notation. default: false */
+ preserve?: boolean,
+};
+
+const gradientFunctionRegex = /(repeating-)?(linear|radial|conic)-gradient\(/i;
+const gradientNameRegex = /^(repeating-)?(linear|radial|conic)-gradient$/i;
+const mathFunctionNameRegex = /^(abs|acos|asin|atan|atan2|calc|clamp|cos|exp|hypot|log|max|min|mod|pow|rem|round|sign|sin|sqrt|tan)$/i;
+
+const creator: PluginCreator = (opts?: pluginOptions) => {
+ const options: pluginOptions = Object.assign(
+ // Default options
+ {
+ preserve: false,
+ },
+ // Provided options
+ opts,
+ );
+
+ return {
+ postcssPlugin: 'postcss-gradient-stop-increments',
+ Declaration(decl) {
+ if (!gradientFunctionRegex.test(decl.value)) {
+ return;
+ }
+
+ const tokens = tokenize({
+ css: decl.value,
+ });
+ if (!tokens.find((token) => token[0] === TokenType.Delim && token[4].value === '+')) {
+ return;
+ }
+
+ const modified = stringify(replaceComponentValues(
+ parseCommaSeparatedListOfComponentValues(tokenize({
+ css: decl.value,
+ })),
+ (componentValue) => {
+ if (!isFunctionNode(componentValue)) {
+ return;
+ }
+
+ const functionName = componentValue.getName();
+ if (!gradientNameRegex.test(functionName)) {
+ return;
+ }
+
+ let lastLengthNode: ComponentValue | null = null;
+ for (let i = 0; i < componentValue.value.length; i++) {
+ const node = componentValue.value[i];
+
+ if (isTokenNode(node) && node.value[0] === TokenType.Delim && node.value[4].value === '+') {
+ const operatorNode = node;
+ const operatorIndex = i;
+
+ while (isCommentNode(componentValue.value[i + 1])) {
+ i++;
+ }
+ i++;
+
+ if (isZeroOrNegative(componentValue.value[i])) {
+ const zeroNode = new TokenNode([TokenType.Number, '0', -1, -1, { value: 0, type: NumberType.Integer }]);
+ componentValue.value.splice(operatorIndex, (i - operatorIndex + 1), zeroNode);
+
+ i = componentValue.value.indexOf(zeroNode);
+ continue;
+ }
+
+ const nextNode = incrementLengthNode(lastLengthNode, operatorNode, componentValue.value[i]);
+ componentValue.value.splice(operatorIndex, (i - operatorIndex + 1), nextNode);
+ lastLengthNode = nextNode;
+
+ i = componentValue.value.indexOf(nextNode);
+ continue;
+ }
+
+ if (isNumericLargerThanZero(node)) {
+ lastLengthNode = maxOfLastAndCurrentLengthNode(lastLengthNode, node);
+ continue;
+ }
+
+ if (isFunctionNode(node) && mathFunctionNameRegex.test(node.getName())) {
+ lastLengthNode = maxOfLastAndCurrentLengthNode(lastLengthNode, node);
+ continue;
+ }
+ }
+
+ return;
+ },
+ ));
+
+ if (modified === decl.value) {
+ return;
+ }
+
+ decl.cloneBefore({
+ value: modified,
+ });
+
+ if (!options?.preserve) {
+ decl.remove();
+ }
+ },
+ };
+};
+
+creator.postcss = true;
+
+export default creator;
+
+function isNumericLargerThanZero(node: ComponentValue): boolean {
+ if (
+ isTokenNode(node) &&
+ (
+ node.value[0] === TokenType.Percentage ||
+ node.value[0] === TokenType.Dimension ||
+ node.value[0] === TokenType.Number
+ ) &&
+ /*
+ Values can only increase.
+ For zero specifically we know that it will be smaller and normalized by browsers.
+ For all other values we wrap with `max(a, b)`.
+ */
+ node.value[4].value > 0
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function isZeroOrNegative(node: ComponentValue): boolean {
+ if (
+ isTokenNode(node) &&
+ (
+ node.value[0] === TokenType.Percentage ||
+ node.value[0] === TokenType.Dimension ||
+ node.value[0] === TokenType.Number
+ ) &&
+ /*
+ Values can only increase.
+ For zero specifically we know that it will be smaller and normalized by browsers.
+ For all other values we wrap with `max(a, b)`.
+ */
+ node.value[4].value <= 0
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+function incrementLengthNode(lastLengthNode: ComponentValue | null, operatorNode: ComponentValue, nextNode: ComponentValue): ComponentValue {
+ if (!lastLengthNode) {
+ return nextNode;
+ }
+
+ const maxLengthNode = new FunctionNode(
+ [TokenType.Function, 'calc(', -1, -1, { value: 'calc' }],
+ [TokenType.CloseParen, ')', -1, -1, undefined],
+ [
+ lastLengthNode,
+ new WhitespaceNode([[TokenType.Whitespace, ' ', -1, -1, undefined]]),
+ operatorNode,
+ new WhitespaceNode([[TokenType.Whitespace, ' ', -1, -1, undefined]]),
+ nextNode,
+ ],
+ );
+
+ const [[solvedLengthNode]] = calcFromComponentValues([[maxLengthNode]]);
+
+ return solvedLengthNode;
+}
+
+function maxOfLastAndCurrentLengthNode(lastLengthNode: ComponentValue | null, newLengthNode: ComponentValue): ComponentValue {
+ if (!lastLengthNode) {
+ return newLengthNode;
+ }
+
+ const maxLengthNode = new FunctionNode(
+ [TokenType.Function, 'max(', -1, -1, { value: 'max' }],
+ [TokenType.CloseParen, ')', -1, -1, undefined],
+ [
+ lastLengthNode,
+ new TokenNode([TokenType.Comma, ',', -1, -1, undefined]),
+ new WhitespaceNode([[TokenType.Whitespace, ' ', -1, -1, undefined]]),
+ newLengthNode,
+ ],
+ );
+
+ const [[solvedLengthNode]] = calcFromComponentValues([[maxLengthNode]]);
+
+ return solvedLengthNode;
+}
+
+// TODO : this should be in css-parser-algorithms, will be present in an upcoming release
+function replaceComponentValues(
+ componentValuesList: Array>,
+ replaceWith: (componentValue: ComponentValue) => ComponentValue | void,
+) {
+ for (let i = 0; i < componentValuesList.length; i++) {
+ const componentValues = componentValuesList[i];
+
+ for (let j = 0; j < componentValues.length; j++) {
+ const componentValue = componentValues[j];
+
+ {
+ const replacement = replaceWith(componentValue);
+ if (replacement) {
+ componentValues.splice(j, 1, replacement);
+ continue;
+ }
+ }
+
+ if (isSimpleBlockNode(componentValue) || isFunctionNode(componentValue)) {
+ componentValue.walk((entry, index) => {
+ if (typeof index !== 'number') {
+ return;
+ }
+
+ const node = entry.node;
+ const replacement = replaceWith(node);
+ if (replacement) {
+ entry.parent.value.splice(index, 1, replacement);
+ return;
+ }
+ });
+ }
+ }
+ }
+
+ return componentValuesList;
+}
+
+// TODO : this should be in css-parser-algorithms, will be present in an upcoming release
+function stringify(componentValueLists: Array>): string {
+ return componentValueLists.map((componentValues) => {
+ return componentValues.map((x) => stringifyTokens(...x.tokens())).join('');
+ }).join(',');
+}
diff --git a/experimental/postcss-gradient-stop-increments/test/_import.mjs b/experimental/postcss-gradient-stop-increments/test/_import.mjs
new file mode 100644
index 000000000..e37e5a21f
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/test/_import.mjs
@@ -0,0 +1,6 @@
+import assert from 'assert';
+import plugin from '@csstools/postcss-gradient-stop-increments-experimental';
+plugin();
+
+assert.ok(plugin.postcss, 'should have "postcss flag"');
+assert.equal(typeof plugin, 'function', 'should return a function');
diff --git a/experimental/postcss-gradient-stop-increments/test/_require.cjs b/experimental/postcss-gradient-stop-increments/test/_require.cjs
new file mode 100644
index 000000000..a2254c05b
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/test/_require.cjs
@@ -0,0 +1,6 @@
+const assert = require('assert');
+const plugin = require('@csstools/postcss-gradient-stop-increments-experimental');
+plugin();
+
+assert.ok(plugin.postcss, 'should have "postcss flag"');
+assert.equal(typeof plugin, 'function', 'should return a function');
diff --git a/experimental/postcss-gradient-stop-increments/test/basic.css b/experimental/postcss-gradient-stop-increments/test/basic.css
new file mode 100644
index 000000000..424e95a3d
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/test/basic.css
@@ -0,0 +1,55 @@
+.basic-1 {
+ background: linear-gradient(red 50%, blue ++1px);
+}
+
+.basic-2 {
+ background: linear-gradient(red 20%, blue ++20px, green ++20px);
+}
+
+.basic-3 {
+ background: conic-gradient(red 60deg, blue ++60deg, green ++60deg, yellow ++60deg);
+}
+
+.basic-4 {
+ background: conic-gradient(red ++60deg, blue ++60deg, green ++60deg, yellow ++60deg);
+}
+
+.basic-5 {
+ background: repeating-linear-gradient(red 0 10px, blue 0 ++10px);
+}
+
+.basic-6 {
+ background: repeating-linear-gradient(red 0 ++10px, blue 0 ++10px);
+}
+
+.basic-7 {
+ background: repeating-linear-gradient(red 20px, blue 0 10vi, green 0 ++10vw);
+}
+
+.basic-8 {
+ background: repeating-linear-gradient(red 20px, blue 0 +-10px, green 0 ++5px);
+}
+
+.basic-9 {
+ background: repeating-linear-gradient(red 20px, blue 0 10vi, green 0 ++10vw, yellow ++5rem, orange ++10ex);
+}
+
+.basic-10 {
+ background: repeating-linear-gradient(red 20px, blue 0 10vi, green 0 ++10vw, yellow 5rem, orange ++10ex);
+}
+
+.calc-1 {
+ background: repeating-linear-gradient(red calc(10px * 2), blue ++5px);
+}
+
+.calc-2 {
+ background: repeating-linear-gradient(red round(11px, 10px), blue ++5px);
+}
+
+.calc-3 {
+ background: repeating-linear-gradient(red 10px blue +calc(5px * 3));
+}
+
+.var-1 {
+ background: repeating-linear-gradient(red 10px blue +var(--foo));
+}
diff --git a/experimental/postcss-gradient-stop-increments/test/basic.expect.css b/experimental/postcss-gradient-stop-increments/test/basic.expect.css
new file mode 100644
index 000000000..99c6d06bd
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/test/basic.expect.css
@@ -0,0 +1,55 @@
+.basic-1 {
+ background: linear-gradient(red 50%, blue calc(50% + +1px));
+}
+
+.basic-2 {
+ background: linear-gradient(red 20%, blue calc(20% + +20px), green calc(calc(20% + +20px) + +20px));
+}
+
+.basic-3 {
+ background: conic-gradient(red 60deg, blue 120deg, green 180deg, yellow 240deg);
+}
+
+.basic-4 {
+ background: conic-gradient(red +60deg, blue 120deg, green 180deg, yellow 240deg);
+}
+
+.basic-5 {
+ background: repeating-linear-gradient(red 0 10px, blue 0 20px);
+}
+
+.basic-6 {
+ background: repeating-linear-gradient(red 0 +10px, blue 0 20px);
+}
+
+.basic-7 {
+ background: repeating-linear-gradient(red 20px, blue 0 10vi, green 0 calc(max(20px, 10vi) + +10vw));
+}
+
+.basic-8 {
+ background: repeating-linear-gradient(red 20px, blue 0 0, green 0 25px);
+}
+
+.basic-9 {
+ background: repeating-linear-gradient(red 20px, blue 0 10vi, green 0 calc(max(20px, 10vi) + +10vw), yellow calc(calc(max(20px, 10vi) + +10vw) + +5rem), orange calc(calc(calc(max(20px, 10vi) + +10vw) + +5rem) + +10ex));
+}
+
+.basic-10 {
+ background: repeating-linear-gradient(red 20px, blue 0 10vi, green 0 calc(max(20px, 10vi) + +10vw), yellow 5rem, orange calc(max(calc(max(20px, 10vi) + +10vw), 5rem) + +10ex));
+}
+
+.calc-1 {
+ background: repeating-linear-gradient(red calc(10px * 2), blue 25px);
+}
+
+.calc-2 {
+ background: repeating-linear-gradient(red round(11px, 10px), blue 15px);
+}
+
+.calc-3 {
+ background: repeating-linear-gradient(red 10px blue 25px);
+}
+
+.var-1 {
+ background: repeating-linear-gradient(red 10px blue calc(10px + var(--foo)));
+}
diff --git a/experimental/postcss-gradient-stop-increments/test/examples/example.css b/experimental/postcss-gradient-stop-increments/test/examples/example.css
new file mode 100644
index 000000000..e30f7e5c8
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/test/examples/example.css
@@ -0,0 +1,15 @@
+.example-1 {
+ background: linear-gradient(red 50%, blue ++1px);
+}
+
+.example-2 {
+ background: conic-gradient(red ++60deg, blue ++10deg ++50deg, green ++60deg, yellow ++60deg);
+}
+
+.example-3 {
+ background: conic-gradient(pink ++60deg, cyan 0 ++20deg, gold 0 ++10deg);
+}
+
+.example-4 {
+ background: linear-gradient(orange 20px, magenta 2vi, aqua ++2vw);
+}
diff --git a/experimental/postcss-gradient-stop-increments/test/examples/example.expect.css b/experimental/postcss-gradient-stop-increments/test/examples/example.expect.css
new file mode 100644
index 000000000..12dcaaedd
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/test/examples/example.expect.css
@@ -0,0 +1,15 @@
+.example-1 {
+ background: linear-gradient(red 50%, blue calc(50% + +1px));
+}
+
+.example-2 {
+ background: conic-gradient(red +60deg, blue 70deg 120deg, green 180deg, yellow 240deg);
+}
+
+.example-3 {
+ background: conic-gradient(pink +60deg, cyan 0 80deg, gold 0 90deg);
+}
+
+.example-4 {
+ background: linear-gradient(orange 20px, magenta 2vi, aqua calc(max(20px, 2vi) + +2vw));
+}
diff --git a/experimental/postcss-gradient-stop-increments/test/examples/example.preserve-true.expect.css b/experimental/postcss-gradient-stop-increments/test/examples/example.preserve-true.expect.css
new file mode 100644
index 000000000..0610e209c
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/test/examples/example.preserve-true.expect.css
@@ -0,0 +1,19 @@
+.example-1 {
+ background: linear-gradient(red 50%, blue calc(50% + +1px));
+ background: linear-gradient(red 50%, blue ++1px);
+}
+
+.example-2 {
+ background: conic-gradient(red +60deg, blue 70deg 120deg, green 180deg, yellow 240deg);
+ background: conic-gradient(red ++60deg, blue ++10deg ++50deg, green ++60deg, yellow ++60deg);
+}
+
+.example-3 {
+ background: conic-gradient(pink +60deg, cyan 0 80deg, gold 0 90deg);
+ background: conic-gradient(pink ++60deg, cyan 0 ++20deg, gold 0 ++10deg);
+}
+
+.example-4 {
+ background: linear-gradient(orange 20px, magenta 2vi, aqua calc(max(20px, 2vi) + +2vw));
+ background: linear-gradient(orange 20px, magenta 2vi, aqua ++2vw);
+}
diff --git a/experimental/postcss-gradient-stop-increments/tsconfig.json b/experimental/postcss-gradient-stop-increments/tsconfig.json
new file mode 100644
index 000000000..500af6d26
--- /dev/null
+++ b/experimental/postcss-gradient-stop-increments/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "declarationDir": ".",
+ "strict": true
+ },
+ "include": ["./src/**/*"],
+ "exclude": ["dist"]
+}
diff --git a/package-lock.json b/package-lock.json
index 5543ac1e6..12d6bc4fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -117,6 +117,29 @@
"postcss": "^8.4"
}
},
+ "experimental/postcss-gradient-stop-increments": {
+ "name": "@csstools/postcss-gradient-stop-increments-experimental",
+ "version": "0.0.0",
+ "license": "CC0-1.0",
+ "dependencies": {
+ "@csstools/css-calc": "1.0.0",
+ "@csstools/css-parser-algorithms": "2.0.1",
+ "@csstools/css-tokenizer": "^2.1.0"
+ },
+ "devDependencies": {
+ "@csstools/postcss-tape": "*"
+ },
+ "engines": {
+ "node": "^14 || ^16 || >=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4"
+ }
+ },
"experimental/postcss-nesting": {
"name": "@csstools/postcss-nesting-experimental",
"version": "2.0.0",
@@ -1901,6 +1924,10 @@
"resolved": "plugins/postcss-global-data",
"link": true
},
+ "node_modules/@csstools/postcss-gradient-stop-increments-experimental": {
+ "resolved": "experimental/postcss-gradient-stop-increments",
+ "link": true
+ },
"node_modules/@csstools/postcss-gradients-interpolation-method": {
"resolved": "plugins/postcss-gradients-interpolation-method",
"link": true