Files
airship/packages/protocol
Nayan abe2cc2431 fix: give a colour one identity, from a hex token to a computed rgb
A colour had as many identities as it had spellings, and every feature that
compares colours was quietly broken by it.

`normalizeTokenValue` never understood hex, so a token authored `--brand: #0af`
and the `rgb(0, 170, 255)` a control reads off the element hashed to two
different keys. The consequence is blunt: no hex-authored colour token has ever
bound to a control. It now collapses 3, 4, 6 and 8-digit hex to the same
four-arg `rgb(r, g, b, a)` form the legacy branch already emitted, alpha
carried, malformed input still rejected. `looksLikeColor` is exported because the
overlay was keeping a byte-identical copy of the same prefix test.

Spelling is only half of it. `rgb(0 0 0)` and `rgb(0, 0, 0)` are the same colour,
and no amount of string normalisation makes `oklch()` and a named colour
comparable — so `sameColor` compares them as colours, with alpha as the 8-bit
channel, because `formatColor` rounds to three decimals and an exact compare on a
rounded value is not exact. `sameValue` in the token matcher falls back to it for
colour-category properties, which is what lets a token match a computed value the
string form cannot reach. It still refuses a colour that is merely close.

`isHexColor` replaces three divergent hex regexes with the grammar `parseHex`
actually implements. They disagreed about the 4 and 8-digit forms, which is why
the row's hex field rejected input the popover accepted.

Then the realm. `withAlpha`, `alphaOf`, `opaque` and `isParseableColor` all
called `parseColor` with no node, which made the realm fix documented in that
file unreachable: a `var()` colour on an element inside a canvas frame resolved
against the overlay shell, where that custom property does not exist. They take
the node now and it threads through every caller — the gates, the paint rows, the
picker, the gradient editor, and the fill, effects and filters sections. An
unresolvable `var(--brand)` no longer reads as a fill because the shell happened
to have an inherited colour.

`gradient.ts`'s `mix()` had its own private hex parser, so clicking the gradient
bar between two `oklch()` stops silently copied the nearer one — Tailwind 4
gradients never interpolated at all. It goes through `parseColor`/`formatColor`
now, and interpolates alpha.

Three picker fixes fall out of the above: the hex field accepts 4 and 8-digit
hex, commits hex as hex instead of forcibly downgrading to `rgb()`, and keeps an
8-digit hex's alpha instead of dropping it. The popover's hex path assigns
`state.a`, where typing `FF000080` used to produce opaque red. Recents still
store the `rgb()` form, so dedupe stays keyed one way.

`MIXED` was declared independently in two files. It moves to `mixed.ts` with
`agreed()`, which folds N values to a shared value or `MIXED` and takes the
comparison as a parameter — so it can be told that two spellings of one colour
are not "Mixed".

Finally `unchanged()` in the change set: a colour edited and then edited back to
where it started was still shipping a do-nothing change to the agent, across
`rgb(a, b, c)` and `rgb(a b c)` alike.
2026-08-16 13:05:37 +05:30
..