lighten(red, 50%) はSassでは真っ白、Stylusでは薄赤

# 概要

SassとStylus、両CSSプリプロセッサには `lighten()` という同名の関数がありながら、その処理は実は違う。
結果の違いや原理の違いを把握し、ついでなのでそれぞれに互換する独自関数を作ってみる。


# 関数紹介

`lighten(color, amount)`

- `color`：明るくする前の色。`red` とか、`#ff0000` とか。
- `amount`：明るくする量。本記事では簡単のために、単位を `10%` とかのパーセントのみ、範囲を0～100のみとする。

## Sassの挙動

```sass:lighten.sass
*
  color: lighten(red, 50%)
  // => color: white;
```

## Stylusの挙動

```stylus:lighten.styl
*
  color: lighten(red, 50%)
  // => color: #ff8080;
```
※ 執筆時点で、QiitaのシンタックスハイライトはStylusに未対応

## 違うね…

```
  // => color: white; // Sass
  // => color: #ff8080; // Stylus
```

違う。

違いに直面した当初は、自分が何をミスってるのかと焦った。
色空間の違いか？ と思ったりもしたけど、調べたところどちらもHSL色空間の輝度（Lightness）を増やす関数なので、値の扱いは同じはず[^hsl]。
結果的には、どのように明るくするかという仕様そのものの違いだった。

なお、併せて述べていくが、暗くする `darken()` や、彩度を変える `saturate()` と `desaturate()` も、やはり両言語間で仕様が違う。

[^hsl]: 正確に言うと、HSL色空間には円柱モデルと双円錐モデルがあり、輝度はどちらでも同じだが彩度は計算が異なる。私の調べた限りではStylusは円柱モデルなので、まぁ多分Sassもそうでしょ（怠惰）


# 何が違うか

以下、公式情報を概訳しながら拾い上げていく。

## Sassの `lighten()`

公式ドキュメント：
https://sass-lang.com/documentation/modules/color#lighten

ご丁寧に目立つ注意書きがある。

> ⚠️ ちょっと待った！
> `lighten()` は、輝度の値を固定的に増加させる関数です。お望みの効果ではない場合があります。
>
> ```sass
> // #e1d7d2の輝度は85%なので、lighten()関数で30%の輝度を足すとただの白になる
> @debug lighten(#e1d7d2, 30%)  // white
> ```
>
> ```sass
> // 輝度 46% → 66%
> @debug lighten(#6b717f, 20%)  // #a1a5af
> 
> // 輝度 20% → 80%
> @debug lighten(#036, 60%)  // #99ccff
> 
> // 輝度 85% → 100%
> @debug lighten(#e1d7d2, 30%)  // white
> ```

つまり、引数がパーセント値でありながらその実態は「旧パーセント値そのものを `amount` だけ底上げする」であり、「旧パーセント値を `amount` の割合だけ増加させる」ではない（伝わるかな…）。
お望みではないかも、というのはこういうことだろう。

なお一般的に、パーセント値そのものが差分的に変動する場合、その差分の単位の名称はパーセントではなく**ポイント**（パーセントポイント、percentage point）となる。
例えば20%が25%になったとしたら、それは「5%増えた」ではなく「5ポイント増えた」という。
何せ、値としての20が5%増えたら $20 \times 1.05 = 21$ になるので、その紛らわしさを回避するためにはそりゃ必要な呼び分け方だろうなという感じがする。
…いやしかし、CSS的には大きさの単位に `pt`（ポイント）があるからやっぱ紛らわしいな……。

ということで、Sassの `lighten()` は元の色の輝度に対してポイント的処理をしている、といえる。

タイトルの例について改めて触れると、

```sass:lighten.sass（再掲）
*
  color: lighten(red, 50%)
  // => color: white;
```

HSL色空間では `red` 等の原色はその時点で既に50%の輝度を持つため、Sassの `lighten(red, 50%)` で輝度が更に50ポイント足された結果、輝度100%の `white` が得られる。

### お仲間の関数について

暗くする `darken()` や、彩度を変える `saturate()` と `desaturate()` についても同じであり、ドキュメントにも全く同様の注意書きがある。

## Stylusの `lighten()`

公式ドキュメント：
https://stylus-lang.com/docs/bifs.html#lightencolor-amount
特に注意書きは無い。

オープンソースなので、ソースを覗いてみる。
https://github.com/stylus/stylus/blob/master/lib/functions/index.styl#L125

> ```stylus:index.styl
> // 与えられた amount で暗くする
> 
> darken(color, amount)
>   adjust(color, 'lightness', - amount)
> 
> // 与えられた amount で明るくする
> 
> lighten(color, amount)
>   adjust(color, 'lightness', amount)
> ```

暗くする方と共に `adjust()` という関数で定義されており、その差は `amount` の正負にある模様。

更に `adjust()` のソースを覗いてみる。
https://github.com/stylus/stylus/blob/master/lib/functions/adjust.js#L13

> ```js:adjust.js（抜粋）
> function adjust(color, prop, amount){
>   utils.assertColor(color, 'color');
>   utils.assertString(prop, 'prop');
>   utils.assertType(amount, 'unit', 'amount');
>   var hsl = color.hsla.clone();
>   prop = { hue: 'h', saturation: 's', lightness: 'l' }[prop.string];
>   if (!prop) throw new Error('invalid adjustment property');
>   var val = amount.val;
>   if ('%' == amount.type){
>     val = 'l' == prop && val > 0
>       ? (100 - hsl[prop]) * val / 100
>       : hsl[prop] * (val / 100);
>   }
>   hsl[prop] += val;
>   return hsl.rgba;
> };
> ```

輝度操作のコア部分の処理をグラフで表すとこんな感じ。
https://www.desmos.com/calculator/m5j1th0unt
<img alt="stylus-lighten.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/225134/03598c3e-d6d1-8afb-76ca-74d18e4f003f.png" width=60%>
緑のラインは元の色の輝度。
入力値が正の領域は `lighten()` に、負の領域は `darken()` に用いられている。

見ての通り、これはSassとは違って「旧パーセント値を `amount` の割合だけ増加させる」ような計算になっている。
この計算なら、元の色によらず常に真っ白や真っ黒になるようなケースは `amount` を `100%` と指定した場合のみとなる。

ということで、Stylusの `lighten()` は元の色の輝度に対して割合的処理をしている、といえる。

タイトルの例について改めて触れると、

```stylus:lighten.styl（再掲）
*
  color: lighten(red, 50%)
  // => color: #ff8080;
```

HSL色空間では `red` 等の原色はその時点で既に50%の輝度を持つため、Stylusの `lighten(red, 50%)` で「輝度50%から100%までの道のりを50%進む」ようにされた結果、輝度75%の `#ff8080` が得られる。

### お仲間の関数について

暗くする `darken()` については既に述べた。

彩度を変える `saturate()` と `desaturate()` については、少し状況が異なる。
上記コア部分の `'l' == prop && val > 0` という場合分けの左辺（？）が示しているように、彩度関数では入力値が正の領域でも負の方と同じ1次関数が使われているため、`amount` を `100%` と指定してもせいぜい元の2倍の彩度にしかならない。
この仕様の意図は正直よくわからない。まぁそういうもんなんだろう。

<p class="codepen" data-height="572" data-theme-id="dark" data-default-tab="css,result" data-user="phroneris" data-slug-hash="BaNJJpb" style="height: 572px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Stylus HSL">
  <span>See the Pen <a href="https://codepen.io/phroneris/pen/BaNJJpb">
  Stylus HSL</a> by 森の子リスのミーコの大冒険 (<a href="https://codepen.io/phroneris">@phroneris</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>

（もしくはこちらから→ [Try Stylus!](https://stylus-lang.com/try.html#?code=c%20%3D%20hsl(0%2C%2020%25%2C%2020%25)%0Arate%20%3D%2010%25%0AsatTo100(c%2C%20rate)%20%2F%2F%20lighten()%E3%81%AE%E8%A3%9C%E6%AD%A3%E6%B3%95%E3%82%92%E5%BD%A9%E5%BA%A6%E3%81%A7%E3%82%82%E5%86%8D%E7%8F%BE%0A%20%20s%20%3D%20unit(saturation(c)%2C%20%22%22)%20%2F%20100%0A%20%20rate%20%3D%20unit(rate%2C%20%22%22)%20%2F%20100%0A%20%20s%20%3D%20percentage(s%20%2B%20(1%20-%20s)%20*%20rate)%0A%20%20return%20saturation(c%2C%20s)%0A.lightness%0A%20%20color%3A%20c%0A%20%20content%3A%20lightness(c)%0A%20%20color%3A%20lighten(c%2C%20rate)%0A%20%20content%3A%20(lighten_%20%2B%20rate)%20lightness(%40color)%0A%20%20color%3A%20darken(c%2C%20rate)%0A%20%20content%3A%20(darken_%20%20%2B%20rate)%20lightness(%40color)%0A.saturation%0A%20%20color%3A%20c%0A%20%20content%3A%20saturation(c)%0A%20%20color%3A%20saturate(c%2C%20rate)%0A%20%20content%3A%20(saturate_%20%20%20%2B%20rate)%20saturation(%40color)%0A%20%20color%3A%20desaturate(c%2C%20rate)%0A%20%20content%3A%20(desaturate_%20%2B%20rate)%20saturation(%40color)%0A%20%20color%3A%20satTo100(c%2C%20rate)%0A%20%20content%3A%20(satTo100_%20%20%20%2B%20rate)%20saturation(%40color))）

「ご覧の通り、下げる方向なら輝度でも彩度でも同じなのに上げる方向では違うよね」…ってやろうとしたんだけど、下げる方向もそれはそれで意味わからん誤差があるな……。
といってもパーセント値の一の位で四捨五入すれば同じだし、この程度なら許容されてるのかな…（なお、`rate = 43%` にすると四捨五入ですら不一致になる）。

あとなんかこんな挙動もある→ [Try Stylus!](https://stylus-lang.com/try.html#?code=c1%20%3D%20%23e8e4e3%0Ac2%20%3D%20hsl(10%2C%2010%25%2C%2090%25)%0A.comparison%0A%20%20color%3A%20c1%0A%20%20color%3A%20c2%0A%20%20content%3A%20hue(c1)%0A%20%20content%3A%20hue(c2)%0A%20%20content%3A%20saturation(c1)%0A%20%20content%3A%20saturation(c2)%0A%20%20content%3A%20lightness(c1)%0A%20%20content%3A%20lightness(c2)%0A%20%20content%3A%20c1%20%20%20%20%20%20%3D%3D%20c2%20%20%20%20%20%20%2F%2F%20%E3%82%8F%E3%81%8B%E3%82%8B%0A%20%20content%3A%20hsl(c1)%20%3D%3D%20hsl(c2)%20%2F%2F%20%E3%81%BE%E3%81%81%E3%82%8F%E3%81%8B%E3%82%8B%0A%20%20content%3A%20hue(c1)%20%20%20%20%20%20%20%20%3D%3D%20hue(c2)%20%20%20%20%20%20%20%20%2F%2F%20%E3%81%9D%E3%81%86%E2%80%A6%0A%20%20content%3A%20saturation(c1)%20%3D%3D%20saturation(c2)%20%2F%2F%20%E3%81%9D%E3%81%86%E2%80%A6%0A%20%20content%3A%20lightness(c1)%20%20%3D%3D%20lightness(c2)%20%20%2F%2F%20%E3%81%9D%E3%81%86%E2%80%A6)


# どちらが良いのか

良いも悪いも無い。
無いが、どちらかのCSSプリプロセッサを主に使いながらもう片方のものにも多少首を突っ込むような使い方をする人の場合、そういう差があるということだけは知っておく必要がある。
…と思う。

## 一応の比較考察

Sass側のポイント加算方式は、正直言って私達が「割合」の操作に期待するものではないところがある。
何せSassでは「`#FF0000`を半分くらい明るくしたい」と思って `lighten(red, 50%)` と書いたら `#FFFFFF` が返ってくるわけで、そりゃ公式ドキュメントにも警告が載るわなという感じがする。

ただ一方で、「サイコロを6回振ったら1回は6が出るはず」とか「ハサミギロチン[^hamichin]を3回撃ったら命中率90%」とか思ってしまうのもまた人情であり、ポイント加算方式はそういう思考にはむしろマッチするのが面白い。
あと、HSL色空間は

- 色相（Hue）：0～360 [°]
- 彩度（Saturation）：0～100 [%]
- 輝度（Lightness）：0～100 [%]

という定義上、彩度と輝度は元々がパーセント値なわけで、HSL色空間についてそこまで把握している人なら「そもそもパーセント値を更に割合操作する方がおかしい」と感じるところがあっても不思議ではないと思う。

[^hamichin]: ポケモンの「一撃必殺技」で、命中率は30%と低いが、当たれば相手を即KOするロマン技。


# じゃあどっちも使おうぜ！

違いはわかった。原理もわかった。
それならいっそ、両言語にある独自関数作成機能で、お相手のそれらに相当する関数を自作してしまおうじゃあないか。
そうすれば、言語を問わずいつでも好きな方を選んで使えるようになる。最強。

以下、各CodePenの右側のResult画面が、どちらの言語の例でも**おおよそ**同じ結果になるのを確認されたし。
誤差は仕方無し。

## SassでStylusの `lighten()` を再現

概訳時にこっそり省いていたが、公式ドキュメントの `lighten()` 等の注意書きには、割合的な変更の実現方法として `scale-color()` が紹介されている。

https://sass-lang.com/documentation/modules/color#scale

> ```
> color.scale($color,
>   $red: null, $green: null, $blue: null,
>   $saturation: null, $lightness: null,
>   $alpha: null)
> scale-color(...) //=> color 
> ```
> 
> `$color` の1つ以上の成分を流動的に拡縮します。

使わない手は無い。

<p class="codepen" data-height="594" data-theme-id="dark" data-default-tab="css,result" data-user="phroneris" data-slug-hash="MWwGXLJ" style="height: 594px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Sass HSL Control: vs Stylus">
  <span>See the Pen <a href="https://codepen.io/phroneris/pen/MWwGXLJ">
  Sass HSL Control: vs Stylus</a> by 森の子リスのミーコの大冒険 (<a href="https://codepen.io/phroneris">@phroneris</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>

（もしくはこちらから→ [SassMeister](https://www.sassmeister.com/gist/7566e0e975285d3becc6f555956e7847)）

## StylusでSassの `lighten()` を再現

単純な足し引きなので楽ちん。

<p class="codepen" data-height="594" data-theme-id="dark" data-default-tab="css,result" data-user="phroneris" data-slug-hash="PoqavNe" style="height: 594px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="Stylus HSL Control: vs Sass">
  <span>See the Pen <a href="https://codepen.io/phroneris/pen/PoqavNe">
  Stylus HSL Control: vs Sass</a> by 森の子リスのミーコの大冒険 (<a href="https://codepen.io/phroneris">@phroneris</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>

（もしくはこちらから→ [Try Stylus!](https://stylus-lang.com/try.html#?code=sass-color-control(col%2C%20rate%2C%20func)%0A%20%20return%20rgb(func(col%2C%20func(col)%20%2B%20rate))%0Asass-lighten(col%2C%20rate)%0A%20%20return%20sass-color-control(col%2C%20%2B%20rate%2C%20lightness)%0Asass-darken(col%2C%20rate)%0A%20%20return%20sass-color-control(col%2C%20-%20rate%2C%20lightness)%0Asass-saturate(col%2C%20rate)%0A%20%20return%20sass-color-control(col%2C%20%2B%20rate%2C%20saturation)%0Asass-desaturate(col%2C%20rate)%0A%20%20return%20sass-color-control(col%2C%20-%20rate%2C%20saturation)%0A%0Acol%20%3D%20%23abcdef%0Arate%20%3D%2050%25%0Ainspect(func)%0A%20%20color%3A%20func(col%2C%20rate)%0A%20%20propFunc%20%3D%20slice(s(%22%25s%22%2C%20func)%2C%20-2)%20%3D%3D%20%22en%22%20%3F%20lightness%20%3A%20saturation%0A%20%20content%3A%20propFunc%20propFunc(%40color)%0A.orig%0A%20%20color%3A%20col%0A%20%20content%3A%20unquote(%22rate%22)%20rate%0A%20%20content%3A%20lightness%20lightness(col)%0A%20%20content%3A%20saturation%20saturation(col)%0A.sass%0A%20%20inspect(sass-lighten)%0A%20%20inspect(sass-darken)%0A%20%20inspect(sass-saturate)%0A%20%20inspect(sass-desaturate)%0A.stylus%0A%20%20inspect(lighten)%0A%20%20inspect(darken)%0A%20%20inspect(saturate)%0A%20%20inspect(desaturate))）

既知の誤差として、Kirakiratterの[ボタンの色](https://github.com/MasterDalK/kktCSS/blob/a09345ea5a41e63628a8eb414303d36cf7f41136/SCSS/kkt/components.scss#L114)を再現すべく `sass-lighten(sass-desaturate(#6a4643, 5%), 12%)` とすると、現物は `#8a6461` なのにこの自作関数では `#8a6460` になる。


# まとめ

SassとStylusという、どちらも同じCSSプリプロセッサの役目を担う言語の、同じ名前の関数であり、「HSL色空間に準じた輝度を増やす」という同じ**ような**機能を持つ、`lighten(color, amount)`。
しかし、その輝度を**どのように**増やすかという点で、両言語のそれには決定的な違いがある。

|:space_invader:|Sass|Stylus|
|:-:|:-:|:-:|
|:computer: 処理|元の輝度に単純に加算|元の輝度から割合的に増加|
|:smile: 利点|元の輝度を知っていれば<br>計算結果が明白|真っ白になるのは<br>`amount = 100%` の時だけ|
|:thinking_face: 欠点|元の輝度を知らない場合は<br>予想外に白飛びする|割合の割合を操作する<br>という疑問が残る|
|:art: 例|`lighten(red, 50%)`<br>→ `white`|`lighten(red, 50%)`<br>→ `#ff8080`

また、輝度を下げる `darken()`、彩度を上げる `saturate()`、彩度を下げる `desaturate()` も同様。
ただし、Stylusの `saturate()` だけはまた少し違う仕様を持つので注意。

そして、頑張ればどちらも自作関数で再現することができる。
ただし、どうしても細かい誤差が出る場合もあり、完全な互換性は望むべくもないので強く生きる。


# 余談

なんでこんなことを気にして調べるに至ったかというと、Stylus（ブラウザ拡張機能の方）で使えるUserCSSとして、マストドンの見た目をカスタムするものを作っている過程で気付いたため。

- ブラウザ拡張機能「Stylus」: https://add0n.com/stylus.html
- Stylus用UserCSS（森）: https://github.com/Phroneris/StylusUserCSS-Phroneris  
  ↑この「Mastodon未収載アイコン変更」に色変え機能を付けようと思って…。

私はもっぱらStylus使い[^sass-beginner]だが、マストドンにはSass（SCSS）が採用されており、両者は記法とか変数とか関数とかが似ていたり違ったりする部分が当然色々ある。
そういう色々について色々格闘していた中で、特に色調整関数の挙動の違いにはかなり困惑し、またそれについて紹介する既存の資料も見当たらなかったので、自分自身のためも兼ねてここにまとめたのであった。

[^sass-beginner]: Sassはマジで今回初めて触ったので、「SassでStylusの `lighten()` を再現」の節は苦労した。変なとこがあったら教えてください。

&#x200B;

おわり

<script async src="https://static.codepen.io/assets/embed/ei.js"></script>