Skip to content

Commit 409ca61

Browse files
committed
feat: implement relative color syntax
1 parent 3cc0461 commit 409ca61

25 files changed

Lines changed: 946 additions & 101 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
packages:
3+
npm:@reactunity/renderer:
4+
type: minor
5+
upm:com.reactunity.core:
6+
type: minor
7+
---
8+
9+
### Relative color syntax
10+
11+
Every color function takes a `from <color>` origin, which is CSS Color 5's relative color syntax:
12+
`rgb()`, `hsl()`, `hsv()`, `lab()`, `lch()`, `oklab()` and `oklch()`. The origin is converted into the
13+
function's own space and its channels become keywords the channel slots can use, on their own or inside
14+
`calc()` — so `rgb(from var(--brand) r g b / 50%)` is a brand color at half opacity without repeating it,
15+
and `oklch(from currentcolor calc(l * 0.8) c h)` is a hover shade of whatever the element's own color
16+
turns out to be. A keyword may appear in any slot, so `rgb(from red b g r)` is blue.
17+
18+
Each keyword carries the range CSS gives it — `r` is `0``255`, `hsl()`'s `s` is `0``100`, `oklch()`'s
19+
`l` is `0``1`, a hue is degrees, `alpha` is `0``1` — so a literal mixes with one exactly as it would in
20+
a browser. An omitted alpha is the origin's own rather than `1`, so `rgb(from <color> r g b)` is that
21+
color unchanged.
22+
23+
The origin stays lazy, so it may be a `var()`, `currentColor`, a `color-mix()` or another relative color,
24+
and one parsed rule resolves against each element it lands on. A literal origin is decomposed while
25+
parsing instead, so the whole function still folds to a constant.
26+
27+
Two things the color functions have always got wrong were in the way, and are fixed:
28+
29+
`hsl()` and `hsv()` read saturation and lightness on CSS's `0``100` scale rather than `0``1`, so the
30+
`%` may be left off as every framework that emits `hsl(152 37 59)` expects. Written with percentages
31+
they are unchanged; written as bare fractions — `hsl(152, 0.37, 0.59)`, which was the only spelling that
32+
used to work — they now mean what CSS says they mean, which is very nearly black.
33+
34+
Out-of-range components are clamped rather than carried through the conversion, which is what CSS
35+
Color 4 asks of a specified color and what this arithmetic reaches constantly: `rgb(300 0 0)` is red,
36+
`hsl(0 150 50)` is a saturation of `100%`, and `calc(l * 4)` is a lightness of `1` rather than a color
37+
that paints nothing. A saturation past `100%` used to come out of the conversion as a different hue
38+
entirely. Alpha is clamped everywhere, hue wraps, and a channel written as `none` is `0` in `rgb()`,
39+
`hsl()` and `hsv()` as it already was in the Lab family.

.tegami/2026-09-14-text-padding.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
packages:
3+
upm:com.reactunity.core:
4+
type: patch
5+
---
6+
7+
### Padding on a text or icon element moves the glyphs
8+
9+
The glyphs of a `text` or an `icon` live on a child that stretches over the whole element, and nothing
10+
inset that child -- so `padding` grew the box and the line stayed where it was, hard against the border
11+
box's own edge, with the whole padding showing as space on the far side of it. A padded badge looked
12+
left-aligned for that reason, and a wrapping line broke against the padded width rather than the width
13+
it was measured at. The child is now inset by the element's padding and border, which is the content box
14+
CSS lays text out in.

docs/src/content/reference/css/background-color.mdx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ Sets the background color of a component.
99
- Known color name: `red`, `limegreen`, `transparent`
1010
- Hex color: `#a5a5a5`, `#777`, `#00ff0033`
1111
- RGBA color: `rgba(23, 16, 168, 0.3)`, `rgb(23 16 168 / 30%)`
12-
- HSL color: `hsl(152, 37%, 59%)`, `hsla(152 37% 59% / 0.75)`
12+
- HSL color: `hsl(152, 37%, 59%)`, `hsla(152 37% 59% / 0.75)`, `hsl(152 37 59)`
1313
- OKLCh color: `oklch(63.7% 0.237 25.331)`, `oklch(0.5 0.2 30deg / 50%)`
1414
- OKLab color: `oklab(0.5 0.1 -0.1)`
1515
- LCh color: `lch(54.29% 106.84 40.86)`, `lch(70 30 0.5turn)`
1616
- Lab color: `lab(50 40 -30)`, `lab(50 40 -30 / 50%)`
1717
- Mixed color: `color-mix(in oklab, red 30%, blue)` — see below
18+
- Relative color: `rgb(from var(--brand) r g b / 50%)` — see below
1819
- CSS keywords: `inherit`
1920

2021
Every component of these four is a number or a percentage, and one written as `none` is `0`. What
@@ -28,7 +29,10 @@ Every component of these four is a number or a percentage, and one written as `n
2829
| `lab()` | `0``100` | `125` |
2930

3031
So `oklch(0.637 …)` and `oklch(63.7% …)` are the same lightness, while in `lch()` both `54.29` and
31-
`54.29%` mean `54.29`. Hue takes any angle unit.
32+
`54.29%` mean `54.29`. In `hsl()` and `hsv()` saturation and lightness run `0``100`, so the `%` may
33+
be left off: `hsl(152 37 59)` is `hsl(152 37% 59%)`. Hue takes any angle unit and wraps, so `512deg`
34+
is `152deg`. A component outside its range is clamped rather than making the declaration invalid, so
35+
`rgb(300 0 0)` is red and `hsl(0 150 50)` is a saturation of `100%`.
3236

3337
`color-mix()` interpolates two colors in `srgb`, `srgb-linear`, `hsl`, `lab`, `lch`, `oklab` or
3438
`oklch`. Each percentage is optional and may be written on either side of its color; giving one
@@ -37,6 +41,34 @@ is premultiplied, so mixing with `transparent` fades the color instead of darken
3741
spaces — `hsl`, `lch` and `oklch` — the hue may carry `shorter hue`, `longer hue`, `increasing hue`
3842
or `decreasing hue`, defaulting to `shorter`.
3943

44+
### Relative colors {/*relative-colors*/}
45+
46+
Every color function above also takes a `from <color>` origin, which is CSS Color 5's relative color
47+
syntax. The origin is converted into the function's own space, and each of its channels becomes a
48+
keyword the channel slots can use — on its own, or inside `calc()`:
49+
50+
```css
51+
.button {
52+
/* A hover shade of whatever the button's own color turns out to be */
53+
background-color: oklch(from var(--brand) calc(l * 0.8) c h);
54+
/* The same color at half opacity, without repeating it */
55+
border-color: rgb(from var(--brand) r g b / 50%);
56+
/* The complement */
57+
outline-color: hsl(from var(--brand) calc(h + 180) s l);
58+
}
59+
```
60+
61+
The keywords are `r g b` in `rgb()`, `h s l` in `hsl()`, `h s v` in `hsv()`, `l a b` in `lab()` and
62+
`oklab()`, `l c h` in `lch()` and `oklch()`, and `alpha` in all of them. Any of them may appear in
63+
any slot, so `rgb(from red b g r)` is blue. Each is worth what the slot would make of that number,
64+
so `r` is `0``255`, `hsl()`'s `s` is `0``100`, `oklch()`'s `l` is `0``1`, a hue is degrees and
65+
`alpha` is `0``1` — the ranges listed above.
66+
67+
An omitted alpha is the origin's own rather than `1`, so `rgb(from <color> r g b)` is that color
68+
unchanged. The origin may be any color, including `currentColor`, a `var()`, a `color-mix()` or
69+
another relative color; one that is not a color makes the declaration invalid. Only the
70+
space-separated form is relative — `rgb(from red, r, g, b)` is not.
71+
4072
A color outside the sRGB gamut is clamped per channel, which shifts the hue of a very saturated
4173
color. Naming an interpolation space that is not in the list above, such as `display-p3` or
4274
`rec2020`, makes the declaration invalid rather than silently mixing in a different space.

kitchen-sink/Assets/Logs.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

kitchen-sink/react/src/pages/game-hud/index.module.scss

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -616,66 +616,92 @@
616616
}
617617

618618
/* --- Rarity ------------------------------------------------------------------------------------
619-
One class per tier, since the border, the glow and the corner wedge all move together. */
619+
A tier is one colour and four numbers. The ring, the glow and the hover lift all read that colour
620+
through `rgb(from ...)` and `hsl(from ...)`, so nothing spells the same three channels out four
621+
times over and a new tier is a handful of custom properties rather than a rule of its own. */
620622

621623
.rarity {
624+
--tier: #94a3b8;
625+
--ring: 0.45;
626+
--halo: 0;
627+
--reach: 0px;
628+
--spread: -4px;
629+
622630
background-image: linear-gradient(160deg, rgba(30, 41, 59, 0.9), rgba(2, 6, 23, 0.92));
631+
box-shadow:
632+
0 0 0 1px rgb(from var(--tier) r g b / var(--ring)) inset,
633+
0 0 var(--reach) var(--spread) rgb(from var(--tier) r g b / var(--halo));
623634
transition:
624635
box-shadow 160ms ease-out,
625636
scale 140ms ease-out;
626637
}
627638

639+
/* Lightening the tier is the one thing an alpha could not do: a translucent ring picks up the slot
640+
behind it, where `l + 14%` is the same hue actually brighter. The glow keeps the tier's own step,
641+
so a common slot answers the pointer without lighting up like a legendary one. */
628642
.rarity:hover {
629643
scale: 1.08;
644+
box-shadow:
645+
0 0 0 1px hsl(from var(--tier) h s calc(l + 14%)) inset,
646+
0 0 calc(var(--reach) + 8px) var(--spread) rgb(from var(--tier) r g b / calc(var(--halo) + 0.1));
630647
}
631648

632649
.rarityCommon {
633-
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.45) inset;
650+
--tier: #94a3b8;
634651
}
635652

636653
.rarityUncommon {
637-
box-shadow:
638-
0 0 0 1px rgba(74, 222, 128, 0.6) inset,
639-
0 0 10px -4px rgba(74, 222, 128, 0.8);
654+
--tier: #4ade80;
655+
--ring: 0.6;
656+
--halo: 0.8;
657+
--reach: 10px;
640658
}
641659

642660
.rarityRare {
643-
box-shadow:
644-
0 0 0 1px rgba(56, 189, 248, 0.7) inset,
645-
0 0 12px -4px rgba(56, 189, 248, 0.9);
661+
--tier: #38bdf8;
662+
--ring: 0.7;
663+
--halo: 0.9;
664+
--reach: 12px;
646665
}
647666

648667
.rarityEpic {
649-
box-shadow:
650-
0 0 0 1px rgba(192, 132, 252, 0.8) inset,
651-
0 0 14px -3px rgba(192, 132, 252, 0.9);
668+
--tier: #c084fc;
669+
--ring: 0.8;
670+
--halo: 0.9;
671+
--reach: 14px;
672+
--spread: -3px;
652673
}
653674

654675
.rarityLegendary {
655-
box-shadow:
656-
0 0 0 1px rgba(251, 191, 36, 0.85) inset,
657-
0 0 16px -2px rgba(251, 191, 36, 0.9);
676+
--tier: #fbbf24;
677+
--ring: 0.85;
678+
--halo: 0.9;
679+
--reach: 16px;
680+
--spread: -2px;
658681
}
659682

660683
/* The only tier that moves on its own, because there is one of it. */
661684
.rarityMythic {
662-
box-shadow:
663-
0 0 0 1px rgba(251, 113, 133, 0.9) inset,
664-
0 0 18px -2px rgba(244, 63, 94, 0.95);
685+
--tier: #fb7185;
686+
--ring: 0.9;
687+
--halo: 0.95;
688+
--reach: 18px;
689+
--spread: -2px;
690+
665691
animation: mythicPulse 2.4s ease-in-out infinite alternate;
666692
}
667693

668694
@keyframes mythicPulse {
669695
from {
670696
box-shadow:
671-
0 0 0 1px rgba(251, 113, 133, 0.9) inset,
672-
0 0 10px -4px rgba(244, 63, 94, 0.7);
697+
0 0 0 1px rgb(from var(--tier) r g b / 0.9) inset,
698+
0 0 10px -4px rgb(from var(--tier) r g b / 0.7);
673699
}
674700

675701
to {
676702
box-shadow:
677-
0 0 0 1px rgba(253, 164, 175, 1) inset,
678-
0 0 22px 0 rgba(244, 63, 94, 1);
703+
0 0 0 1px hsl(from var(--tier) h s calc(l + 10%)) inset,
704+
0 0 22px 0 rgb(from var(--tier) r g b / 1);
679705
}
680706
}
681707

0 commit comments

Comments
 (0)