Sass Built-in Module'leri 2: sass:map ve sass:color ile İç İçe Token Map'leri, deep-merge ve Color Space Farkında Renk Türetme
İç içe token map'ini map.merge() ile değil map.deep-merge() ile override et, renk tonlarını HSL'de değil OKLCH'de türet ve sonucu color.to-gamut()'tan geçir; yoksa iç key'ler sessizce kaybolur, aynı adım renkten renge 8 ile 14 puan oynar ve gamut dışı renkleri tarayıcı keser. Bookstore'a OKLCH'de üretilen hover, active ve subtle tonları ile deep-merge'le kurulan bir kurumsal portal teması ekliyoruz.
Sürüm notu: Bu yazıdaki örnekler Dart Sass 1.108.2, Angular 22.2 (TypeScript 6.0; Angular CLI'ın içindeki Sass 1.104.1) ve Playwright 1.65'in Chromium build'iyle denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kurumsal portalı, yani okul ve şirketlerin toplu sipariş verdiği bölüm, aynı tasarımı farklı bir marka rengiyle kullanacak. Varsayılan token map'i ve portalın tek değişikliği:
@use "sass:map";
$defaults: (
color: (
brand: #b45309,
surface: #ffffff,
text: #1c1917,
),
radius: 8px,
);
// kurumsal portal yalnız marka rengini değiştiriyor
$institutional: map.merge($defaults, (color: (brand: #1d4ed8)));
.portal-header {
background-color: map.get($institutional, color, surface);
color: map.get($institutional, color, text);
border-bottom: 2px solid map.get($institutional, color, brand);
border-radius: map.get($institutional, radius);
}
Derlenmiş .portal-header kuralında kaç declaration olur? Derleme bir uyarı verir mi?
Önceki yazıda sass:math, sass:string ve sass:list ile sayı ve list işlemlerini gördük. Bu yazıda built-in module'lerin en çok kullanılan iki tanesine geçiyoruz: iç içe token map'leri için sass:map ve 1.79.0'dan beri color space'leri bilen sass:color. Sonunda Bookstore'un Angular istemcisine (bookstore-web) OKLCH'de (algıya dayalı bir renk uzayı; lightness değeri gözün gördüğü parlaklığa yakın) türetilmiş hover ve active tonları ile kurumsal portal teması ekleyeceğiz.
sass:map: İç İçe Key Yolu ile Okumak ve Yazmak
Tahminin cevabı:
.portal-header {
border-bottom: 2px solid #1d4ed8;
border-radius: 8px;
}
Arka plan ve yazı rengi yok; uyarı da yok. map.merge() yalnız en üst seviyede birleştiriyor: color key'inin değeri, yani üç renkli iç map, tek renkli yeni map'le tamamen değiştirildi. surface ve text artık yok, map.get() onlar için null döndürüyor ve Sass değeri null olan declaration'ı sessizce çıktıdan atıyor. Portal başlığı beyaz arka planını ve koyu yazı rengini kaybediyor, bunu ancak sayfaya bakan biri fark ediyor.
Önce module'ün okuma ve yazma tarafına bakalım. Serinin beşinci yazısında tema map'lerini map.get($themes, light) gibi tek seviyeli kullanmıştık; map.get() ve diğer function'lar birden fazla key alıp iç içe map'te yol izleyebiliyor:
@use "sass:map";
$tokens: (
color: (
brand: (base: #b45309, hover: #92400e),
surface: #ffffff,
),
radius: 8px,
);
.map-demo {
a: map.get($tokens, color, brand, hover);
b: map.get($tokens, color, brand, focus);
c: map.has-key($tokens, color, brand, base);
d: map.keys(map.get($tokens, color));
e: map.values(map.get($tokens, color, brand));
f: map.keys(map.get(map.set($tokens, color, brand, focus, #78350f), color, brand));
g: map.keys(map.remove($tokens, radius));
h: map.keys(map.get(map.deep-remove($tokens, color, brand, hover), color, brand));
i: map.keys(map.get($tokens, color, brand));
}
.map-demo {
a: #92400e;
c: true;
d: brand, surface;
e: #b45309, #92400e;
f: base, hover, focus;
g: color;
h: base;
i: base, hover;
}
b satırı çıktıda yok: olmayan bir yol hata değil, null. map.set() son argümanı değer, öncekileri yol olarak alıyor ve ara map'ler yoksa oluşturuyor. map.remove() yalnız üst seviyeden siliyor, map.deep-remove() bir yolun sonundaki key'i. Son satır önemli: f ve h'deki değişikliklerden sonra $tokens'ın kendisi değişmedi. Sass'ta map'ler de list'ler gibi immutable; her function yeni bir map döndürüyor ve sonucu bir değişkene atamazsan değişiklik kayboluyor.
Map'i doğrudan CSS'e yazmaya çalışırsan derleme duruyor: (base: #b45309, hover: #92400e, focus: #78350f) isn't a valid CSS value. Yukarıda bu yüzden map.keys() ile okudum.
merge ve deep-merge
Tahmindeki sorunun çözümü map.deep-merge(): iki tarafta da map olan key'leri özyinelemeli (recursive) olarak birleştiriyor.
$override: (color: (brand: #1d4ed8));
$shallow: map.merge($defaults, $override);
$deep: map.deep-merge($defaults, $override);
.merge-demo {
shallow: map.keys(map.get($shallow, color));
deep: map.keys(map.get($deep, color));
deep-brand: map.get($deep, color, brand);
defaults-brand: map.get($defaults, color, brand);
}
.merge-demo {
shallow: brand;
deep: brand, surface, text;
deep-brand: #1d4ed8;
defaults-brand: #b45309;
}
map.merge() de yanlış bir function değil; düz bir map'e birkaç key eklemek ya da bir iç map'i bilerek tamamen değiştirmek için doğru araç. Kural basit: token map'leri iç içe ise override her zaman deep-merge ile yapılmalı.
with() ile Override'ı Varsayılanlarla Birleştirmek
Serinin dördüncü yazısında !default ve with() ile bir module'ü dışarıdan ayarlamıştık; orada her ayar ayrı bir değişkendi. Token sayısı arttıkça tek bir $config map'i almak ve onu varsayılanlarla deep-merge etmek daha ölçeklenebilir. Bu yapının zayıf noktası yazım hatası: brnad diye yazılmış bir key deep-merge ile sessizce eklenir ve hiçbir şeyi değiştirmez. Module'e bunu kontrol eden küçük bir function ekledim:
@use "sass:map";
@use "sass:meta";
$-defaults: (
color: (
brand: #b45309,
surface: #ffffff,
text: #1c1917,
),
radius: 8px,
);
// çağıranın override'ları: @use "portal-theme" with ($config: (...))
$config: () !default;
// override'daki her key varsayılanlarda olmalı: yazım hatalarını yakalar
@function -check($override, $defaults, $path: "") {
@each $key, $value in $override {
@if not map.has-key($defaults, $key) {
@error "Unknown theme key '#{$path}#{$key}'. Expected one of: #{map.keys($defaults)}";
}
$default: map.get($defaults, $key);
@if meta.type-of($value) == map and meta.type-of($default) == map {
$_: -check($value, $default, "#{$path}#{$key}.");
}
}
@return true;
}
$_: -check($config, $-defaults);
$theme: map.deep-merge($-defaults, $config);
@use "sass:map";
@use "portal-theme" with (
$config: (
color: (
brand: #1d4ed8,
),
)
);
.portal-header {
background-color: map.get(portal-theme.$theme, color, surface);
color: map.get(portal-theme.$theme, color, text);
border-bottom: 2px solid map.get(portal-theme.$theme, color, brand);
border-radius: map.get(portal-theme.$theme, radius);
}
.portal-header {
background-color: #ffffff;
color: #1c1917;
border-bottom: 2px solid #1d4ed8;
border-radius: 8px;
}
Dört declaration da yerinde. brand yerine brnad yazınca:
Error: "Unknown theme key 'color.brnad'. Expected one of: brand, surface, text"
╷
24 │ $_: -check($value, $default, "#{$path}#{$key}.");
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
╵
09/_portal-theme.scss 24:17 -check()
09/_portal-theme.scss 30:5 @use
09/portal-typo.scss 2:1 root stylesheet
Birkaç ayrıntı: function'lar da kendini çağırabiliyor (recursion), iç map'ler bu sayede her seviyede kontrol ediliyor. Sass'ta değer döndüren bir function'ı sonucunu kullanmadan çağırmanın yolu yok; $_: ataması bu yüzden var. meta.type-of() değerin tipini string olarak veriyor; sass:meta'yı ve argüman doğrulamayı kendi kütüphanemizi yazarken ayrıntılı göreceğiz. Stack trace'teki @use satırı da kontrolün module yüklenirken, yani bir kez çalıştığını gösteriyor.
sass:color: Color Space Farkında Function'lar
Dart Sass 1.79.0 ile renkler bir color space bilgisi taşımaya başladı ve sass:color'daki function'lar $space parametresi aldı. Bookstore'un marka rengiyle:
@use "sass:color";
$brand: #b45309;
.color-demo {
a: color.adjust($brand, $lightness: -10%, $space: oklch);
b: color.scale($brand, $lightness: 40%, $space: oklch);
c: color.change($brand, $alpha: 0.5);
d: color.channel($brand, "hue", $space: oklch);
e: color.to-space($brand, oklch);
f: color.mix($brand, white, 50%);
g: color.mix($brand, white, 50%, $method: oklch);
h: color.is-legacy($brand) color.is-legacy(color.to-space($brand, oklch));
i: color.same(#b45309, rgb(180 83 9));
j: color.is-in-gamut(oklch(70% 0.3 50deg), $space: rgb);
k: color.to-gamut(oklch(70% 0.3 50deg), $space: rgb, $method: local-minde);
l: oklch(70% 0.3 50deg);
}
.color-demo {
a: hsl(27.5115255796, 146.3097269007%, 23.37604166%);
b: rgb(94.3990869196%, 54.1097508392%, 30.8755981583%);
c: rgba(180, 83, 9, 0.5);
d: 48.9975238238deg;
e: oklch(55.5283112356% 0.1455052079 48.9975238238deg);
f: rgb(85.2941176471%, 66.2745098039%, 51.7647058824%);
g: rgb(86.9864141124%, 66.4289667426%, 55.6100374957%);
h: true false;
i: true;
j: false;
k: oklch(70.1845509996% 0.2006638896 44.9981853813deg);
l: oklch(70% 0.3 50deg);
}
Üç function'ın farkı serinin dördüncü yazısından tanıdık: adjust bir kanala sabit miktar ekliyor, scale kalan mesafenin yüzdesi kadar ilerliyor, change kanalı doğrudan bir değere ayarlıyor. $space: oklch hesabın hangi uzayda yapılacağını söylüyor; sonuç yine girdinin uzayına çevriliyor. #b45309 bir legacy renk (is-legacy true: rgb, hsl, hwb ve hex), bu yüzden a ve b yine rgb ailesinde çıktı. color.channel() herhangi bir uzaydaki kanalı okuyor; serinin üçüncü yazısında badge yazı rengini seçerken kullandığımız da buydu. color.mix() varsayılan olarak eski davranışı koruyor; $method: oklch verince karışım algısal uzayda yapılıyor ve sonuç biraz farklı. color.same() iki rengi yazılışlarından bağımsız karşılaştırıyor; == farklı uzaydaki aynı rengi eşit saymıyor.
a satırındaki 146%'lık saturation bir hata değil, bu yazının en önemli ayrıntısı; ona birazdan döneceğiz.
Eski function'lar hâlâ çalışıyor ama iki ayrı uyarı veriyor:
DEPRECATION WARNING [color-functions]: darken() is deprecated. Suggestions:
color.scale($color, $lightness: -26.9841269841%)
color.adjust($color, $lightness: -10%)
More info: https://sass-lang.com/d/color-functions
darken() ve transparentize() gibi function'lar Dart Sass 2.0'da hata olacak (serinin yedinci yazısı). Uyarının önerdiği karşılıklar eski sonucu birebir üretiyor, yani HSL'de (ton, doygunluk ve açıklıkla tanımlanan renk modeli) çalışıyor. Migration'da bu doğru seçim; yeni kodda ise hangi uzayda türettiğine bilinçli karar vermek gerekiyor.
HSL mi OKLCH mi? Algısal Açıklık ve Gamut
HSL'deki lightness, gözün gördüğü parlaklıkla aynı şey değil. Bunu ölçmek için Bookstore'un üç rengini (marka, kurumsal portal mavisi, stokta yeşili) önce HSL'de, sonra OKLCH'de 10 puan koyulaştırıp 20 puan açtım ve her sonucun OKLCH lightness'ını, yani algısal açıklığını okudum:
@use "sass:color";
@use "sass:math";
// Bookstore'un markası, kurumsal portalın markası, stokta yeşili
$brands: ("amber": #b45309, "blue": #1d4ed8, "green": #15803d);
// algısal açıklık: oklch "l" kanalı
@function lightness($c) {
@return math.round(color.channel($c, "lightness", $space: oklch));
}
@each $name, $c in $brands {
.#{$name} {
base: lightness($c);
hsl-minus-10: lightness(color.adjust($c, $lightness: -10%, $space: hsl));
oklch-minus-10: lightness(color.adjust($c, $lightness: -10%, $space: oklch));
hsl-plus-20: lightness(color.adjust($c, $lightness: 20%, $space: hsl));
oklch-plus-20: lightness(color.adjust($c, $lightness: 20%, $space: oklch));
}
}
| Renk | Başlangıç | HSL −10 | OKLCH −10 | HSL +20 | OKLCH +20 |
|---|---|---|---|---|---|
#b45309 | 56% | 44% (−12) | 46% (−10) | 73% (+17) | 76% (+20) |
#1d4ed8 | 49% | 41% (−8) | 39% (−10) | 67% (+18) | 69% (+20) |
#15803d | 53% | 39% (−14) | 43% (−10) | 77% (+24) | 73% (+20) |
HSL'de aynı "−10" adımı yeşili 14, maviyi 8 puan koyulaştırıyor. Bir tasarım sisteminde bu, aynı kuralla üretilen hover tonlarının bazı renklerde belirgin, bazılarında zor fark edilir olması demek. OKLCH'de adım her renkte aynı. Bookstore'da tonları bu yüzden OKLCH'de türetiyoruz.
Gelelim 146%'ya. #b45309'u OKLCH'de 10 puan koyulaştırınca çıkan renk sRGB'nin (ekranların varsayılan renk uzayı) dışında kalıyor: color.is-in-gamut() false diyor. Sass sonucu legacy uzaya çevirirken renk aralığına sığdırmıyor, kanalları olduğu gibi yazıyor ve ortaya 146% saturation'lı bir hsl() çıkıyor. Tarayıcı ne yapıyor? Üç değeri Chromium'a verdim:
@use "sass:color";
$brand: #b45309;
$hover: color.adjust($brand, $lightness: -10%, $space: oklch);
.gamut-demo {
raw: $hover;
in-gamut: color.is-in-gamut($hover);
mapped: color.to-gamut($hover, $method: local-minde);
clipped: color.to-gamut($hover, $method: clip);
}
node scripts/09-gamut.mjs
raw rgb(119, 55, 0)
mapped rgb(147, 52, 0)
clipped rgb(147, 52, 0)
Tarayıcı 146%'yı 100%'a kesiyor ve bambaşka bir renk çiziyor: rgb(119, 55, 0)'ın OKLCH lightness'ı 41%. İstediğimiz 46% idi; 10 puanlık adım 15 puana dönüştü. color.to-gamut() rengi sRGB içine getiriyor; local-minde CSS Color 4 spesifikasyonunun şu an önerdiği yöntem: OKLCH'de chroma'yı (renk doygunluğu) azaltarak sRGB içindeki en yakın rengi arıyor. clip ise her kanalı sınırına kesiyor. Bu renkte ikisi aynı sonucu verdi; doygun mavilerde ve yeşillerde farklılaşıyorlar. Kural: OKLCH'de türetip legacy uzaya dönen her renk color.to-gamut()'tan geçmeli. Sass 1.x bunu kendisi yapmıyor.
Derleme Zamanı mı, Tarayıcı mı?
Aynı hover tonunu tarayıcıda da üretmek mümkün. CSS3 serisinin renk yazısında anlatılan relative color syntax ve color-mix() ile:
:root { --color-brand: #b45309; }
.relative { color: oklch(from var(--color-brand) calc(l - 0.08) c h); }
.mix { color: color-mix(in oklch, var(--color-brand), black 15%); }
relative oklch(0.475288 0.145526 48.9931)
mix oklch(0.471995 0.123697 48.9931)
Kararı şu soruyla veriyorum: temel renk derleme anında biliniyor mu? Bookstore'un markası ve kurumsal portalın rengi tasarım dosyasında sabit; tonları Sass'ta üretmek, tarayıcıya hazır değerler göndermek, her tarayıcıda aynı sonucu almak ve kontrastı derlenmiş CSS üzerinden ölçebilmek demek. Renk runtime'da geliyorsa (müşterinin kendi logosunun rengi, kullanıcı tercihi, white-label bir tema) Sass o rengi hiç görmüyor; ton türetme tarayıcıda, relative color ya da color-mix() ile yapılmalı. İkisini karıştırmak da mümkün: Sass sabit tonları üretir, runtime'da gelen tek değer için CSS hesaplar.
Bookstore'da: Marka Tonları ve Kurumsal Portal Teması
bookstore-web'de tonları üreten iki function'ı abstracts/_colors.scss'e koydum:
@use "sass:color";
@use "sass:map";
// $color'ın bir tonu: lightness OKLCH'de kaydırılır, sonra sRGB'ye sığdırılır
@function tone($color, $lightness: 0%, $chroma: 0%) {
$result: color.adjust($color, $lightness: $lightness, $space: oklch);
$result: color.scale($result, $chroma: $chroma, $space: oklch);
@return color.to-gamut($result, $space: rgb, $method: local-minde);
}
// tema map'ine <key>-hover, <key>-active ve <key>-subtle ekler;
// $direction: -1 koyulaştırır (açık tema), 1 açar (koyu tema)
@function with-states($theme, $key, $direction) {
$base: map.get($theme, $key);
// açık temada çok açık, koyu temada çok koyu bir arka plan tonu
$subtle-lightness: 96%;
@if $direction > 0 {
$subtle-lightness: 28%;
}
$subtle: color.change($base, $lightness: $subtle-lightness, $space: oklch);
@return map.merge($theme, (
"#{$key}-hover": tone($base, $direction * 8%),
"#{$key}-active": tone($base, $direction * 14%),
"#{$key}-subtle": tone($subtle, $chroma: -60%),
));
}
_tokens.scss'teki $themes artık iki adımda kuruluyor: temel renkler private bir map'te duruyor, with-states() her temaya üç yeni key ekliyor. Kurumsal portal yalnız marka rengini değiştiriyor ve deep-merge ile temel map'in üstüne yazılıyor:
// kurumsal portal yalnız marka rengini değiştiriyor
$-institutional: (
light: (brand: #1d4ed8),
dark: (brand: #60a5fa),
);
@function -with-brand-states($themes) {
@return (
light: colors.with-states(map.get($themes, light), brand, -1),
dark: colors.with-states(map.get($themes, dark), brand, 1),
);
}
$themes: -with-brand-states($-base);
$institutional-themes: -with-brand-states(map.deep-merge($-base, $-institutional));
Beşinci yazıdaki color-tokens() mixin'i hiç değişmedi; yeni key'leri de light-dark() ile custom property'ye çeviriyor. themes/_index.scss'e yalnız portal için bir blok eklendi:
html {
// ...
// kurumsal portal: aynı token'lar, farklı marka
&.institutional {
@include color-tokens($institutional-themes);
}
}
BookCard'ın hover kenarlığı var(--color-brand-hover), öne çıkan kartın arka planı var(--color-brand-subtle) oldu. Dört durumu Playwright ile ölçtüm (portal için <html>'e institutional class'ı ekleniyor, hover için ikinci kartın üstüne geliniyor):
node scripts/09-web-tones.mjs
light brand=rgb(180, 83, 9) hover=rgb(153, 59, 0) subtle=rgb(255, 233, 215)
light portal brand=rgb(29, 78, 216) hover=rgb(8, 50, 188) subtle=rgb(229, 242, 255)
dark brand=rgb(245, 158, 11) hover=rgb(255, 186, 81) subtle=rgb(61, 34, 0)
dark portal brand=rgb(96, 165, 250) hover=rgb(129, 191, 255) subtle=rgb(20, 42, 68)
Açık temada hover koyulaşıyor, koyu temada açılıyor; portal class'ı yalnız marka ailesini değiştiriyor. Kurumsal portal tamamen ayrı bir ürün değil, aynı Bookstore; tek bir class ve altı token farkı.
Üretimde Dikkat
Ürettiğin CSS'in boyutunu izle. Global stil dosyası 10,74 kB'tan 11,71 kB'a çıktı. Bunun bir kısmı yeni tonlar; bir kısmı ise html.institutional bloğunun değişmeyen token'ları da (surface, text…) yeniden yazması. Beş temel renkte önemsiz; yüz token'lık bir sistemde portal başına yalnız farklı key'leri üretmek gerekir (aşağıdaki deneme).
Kontrastı derlenmiş değerler üzerinde ölç. OKLCH adımları algısal olarak eşit, ama bu hover tonunun arka planla yeterli kontrastı olduğunu garanti etmiyor. Tonlar derleme anında bilindiği için kontrast kontrolünü build'e koymak kolay; ölçüm araçları Accessibility (a11y) serisinde.
Uzun ondalıklı rgb() değerleri normal. Sass 1.79.0'dan beri kanalları yuvarlamıyor; rgb(60.1581403468%, 22.9667090617%, 0%) geçerli CSS ve tarayıcı onu kendi hassasiyetinde işliyor. Okunabilirlik gerekiyorsa sonucu color.to-space($c, oklch) ile OKLCH olarak yazmak da bir seçenek, ama o zaman eski tarayıcı desteğini kontrol etmek gerekiyor.
Sık Düşülen Tuzaklar
Tonları lighten()/darken() ile, yani HSL'de üretmek. Yukarıdaki tabloda gördüğün gibi aynı adım renkten renge 8 ile 14 puan arasında oynuyor. Üstelik bu function'lar Dart Sass 2.0'da hata.
İç içe map'i map.merge() ile override etmek. Tahmindeki gibi iç map'in diğer key'leri kayboluyor ve null değerli declaration'lar uyarısız atılıyor. Override her zaman map.deep-merge() ile; olmayan key'leri yakalamak için de bir kontrol.
OKLCH'de türetip to-gamut()'u unutmak. Sass gamut dışındaki rengi olduğu gibi yazıyor, tarayıcı kesiyor ve 10 puanlık adım 15 puana dönüşüyor. Bunu gözle fark etmek zor; ancak ölçünce görünüyor.
Renk adlarını tırnaksız map key'i yapmak. (blue: #1d4ed8, green: #15803d) map'indeki blue bir string değil, renk. Interpolation ile class adı üretirken Sass uyarıyor:
WARNING: You probably don't mean to use the color value blue in interpolation here.
It may end up represented as blue, which will likely produce invalid CSS.
Always quote color names when using them as strings or map keys (for example, "blue").
Bugünkü çıktıda class adı yine .badge-blue oluyor, ama uyarının söylediği gibi bu bir renk değerinin yazımı ve hep böyle kalacağının garantisi yok. Key'leri her zaman tırnakla yaz: ("blue": #1d4ed8).
Küçük Bir Deneme
★☆☆ 09/tones.scss'e stok badge'indeki sarıyı (#fbbf24) ekle. HSL'de 20 puan açınca algısal açıklık ne kadar değişiyor? Sonucu tablodaki üç renkle karşılaştır.
★★☆ 09/gamut.scss'te $brand'ı #15803d yap ve lightness adımını +25% yap. local-minde ile clip farklı sonuç veriyor mu? İkisinin OKLCH lightness ve hue değerlerini color.channel() ile oku.
★★★ color-tokens()'a ikinci bir mixin ekle: color-token-overrides($base, $override). Yalnız $override'da değeri $base'den farklı olan key'ler için custom property üretsin. html.institutional bloğunu bununla yeniden yaz ve global CSS boyutundaki farkı ölç.
Bir sonraki yazıda map'lerden kural üretmeye dönüyoruz ama bu kez tam bir API ile: breakpoint map'inden up, down, between ve only mixin'leri, @content(args), container query mixin'i ve responsive utility class'larını üretip CSS boyutunu kontrol altında tutmak.