−
100%
+
fit
scroll
# Color Chain — Rebirth (Day 382) Corrected
model from design discussion, superseding the
"collapse Color" and "Swatch→Swatch alias"
detours explored earlier same day (see
[[Palette-Swatch-Color-Chain.00382]]). This is
the model going forward — not yet
implemented in Portal. ## The model **Color
has no invented name. Its identity is derived
from the value itself — and that value is
OKLCH, not hex.** The original cut of this
model used hex as the identity (`FF0000`
instead of `sacred-red`/`blood-red`).
Superseded by the gamut/accuracy discussion
below: hex is sRGB at 8 bits/channel — a
narrow gamut with visible quantization —
while OKLCH is perceptually uniform and
gamut-flexible. So the real identity is the
normalized OKLCH naming string
(`nnn_nn-n_nnn-nnn_n`, spec below), and hex
becomes a **derived display value**, not the
source of truth. - A Color Thing's identity is
its normalized OKLCH name (e.g.
`062_50-0_130-210_0`), not an invented word
and not raw hex. - Color carries hex, RGB,
HSB, ANSI — whatever's needed for direct
consumption — as **derived fields computed
from the OKLCH value**, not the other way
around. - **Swatch is the only place a
human-meaningful name exists** — `error`,
`hot`, `ache.identity`, `sacred-accent`, etc.
Swatch links to a Color by its OKLCH name.
**Sharing is automatic, not curated.** Two
Swatches (`error`, `hot`) pointing at the same
Color (same normalized OKLCH name) are sharing
*because they reference the same value*, full
stop — no judgment call about "is this Color
generic enough to justify a shared row," no
invented intermediate name to coordinate. This
was the actual flaw in the live data: Colors
like `ache-identity-orange` or `sacred-red`
are invented names that happen to look
swatch-derived, because someone minted a name
where the value itself should have been the
identifier. **Palette → Swatch → Color**,
same shape as before, just with Color's
identity fixed — and fixed twice over: first
by dropping invented names for the raw value,
then by using the more accurate value (OKLCH)
instead of hex as that identity. ## What's
wrong in the current schema -
`Portal/rust/src/things/theme.rs:18-22,37,61`
and
`Portal/rust/src/server/at_routes.rs:7121-7167`
key the `colors` map by Color's `Gem::Name`
field (an invented string), and
`Swatch.linked_color` (in `Gem::Mark`) stores
that same invented string as the join key —
not the hex. - This produced the false signal
investigated earlier: ~19 of 25 Color Things
are 1:1 with a single Swatch and exist only
because a name was invented for them; only 6
(`sacred-red`, `moss-green`, `spring-green`,
`gold`, `bone-white`, `portal-cyan`) are
actually referenced by more than one Swatch
today. Under the hex-as-name model this
distinction disappears — sharing is just "do
two Swatches cite the same hex," not a
property of how the Color happened to get
named. - Separately (orthogonal bug, not part
of this model fix): `GET /api/palette/:name`
resolves Palette metadata correctly but the
swatch query (`at_routes.rs:7145-7146`) has no
palette filter — it returns every
`sapphire=swatch` Thing in the DB regardless
of `:name`. Confirmed via `curl
/api/palette/timepiece-palette` returning the
identical swatch set as
`/api/palette/hexcraft-dark`. Two Palette
Things exist (`hexcraft-dark`,
`timepiece-palette`) but only one is
functionally in effect. Not fixed here —
flagged for whenever Palette membership
becomes real. ## Every swatch, regrouped by
value (the rebirth view) Old table
([[Palette-Swatch-Color-Chain.00382]]) listed
each Swatch with whatever invented Color name
it happened to link to — that made sharing
look like a property of naming. Regrouped by
the underlying color value, sharing is just
whichever rows land in the same bucket.
**Bold** groups are real multi-Swatch shares;
the rest are singles that needed no Color-name
invention at all. Grouped here by hex (the
data we actually have today) as a stand-in —
the real post-migration grouping key is the
normalized OKLCH name (spec below), not hex.
Converting all 35 of these hex values to their
OKLCH names is unfinished work, not done in
this pass; the grouping/sharing logic itself
is unaffected by which of the two values is
used as the key, since OKLCH and hex are just
two representations of the same color. | Hex
(today's stand-in value) | Swatches | Today's
invented Color name(s) | Note |
|---|---|---|---| | **`FF0000`** | `error`,
`sacred-accent`, `terminal-red` | `blood-red`
**and** `sacred-red` (two different names!) |
**Duplicate Color rows for one hex** —
`error`→`blood-red`, the other
two→`sacred-red`. Proves the invented-name
layer actively fragments what should be one
row. | | **`FFD700`** | `grimoire.right-hand`,
`subu.today` | `gold` (consistent) | Real
share, correctly named once — still
redundant once hex is the key. | |
**`FFFFFF`** | `terminal-cursor`,
`terminal-foreground` | `bone-white`
(consistent) | Real share. | | **`00FF00`** |
`success`, `terminal-green` | `moss-green`
(consistent) | Real share. | | **`00FFFF`** |
`command`, `terminal-cyan` | `portal-cyan`
(consistent) | Real share. | | **`FF00FF`** |
`sacred`, `terminal-magenta` |
`sacred-magenta` (consistent) | Real share. |
| **`33CC33`** | `ache.chip`, `subu.minor` |
`spring-green` (consistent) | Real share. | |
`8E44AD` | `ache.arcana` |
`ache-arcana-purple` | Single — invented
name added nothing. | | `F39C12` |
`ache.chronicle` | `ache-chronicle-amber` |
Single. | | `D4A017` | `ache.economy` |
`ache-economy-gold` | Single. | | `50C878` |
`ache.fid` | `ache-fid-green` | Single. | |
`4269E1` | `ache.gap` | `ache-gap-blue` |
Single — note: visually close to `4169E1`
below but a distinct hex, correctly not
merged. | | `E67E22` | `ache.identity` |
`ache-identity-orange` | Single. | | `27AE60`
| `ache.infrastructure` | `ache-infra-green` |
Single. | | `9B59B6` | `ache.protocol` |
`ache-protocol-purple` | Single. | | `4A90D9`
| `ache.scroll` | `ache-scroll-blue` | Single.
| | `2ECC71` | `ache.system` |
`ache-system-green` | Single. | | `556677` |
`ache.unknown` | `ache-unknown-gray` | Single.
| | `4169E1` | `grimoire.left-hand` |
`royal-blue` | Single — distinct from
`ache.gap`'s `4269E1` above. | | `9370DB` |
`grimoire.membrane` | `orchid-purple` |
Single. | | `88AAFF` | `hexcube.air` |
`air-blue` | Single. | | `44AA44` |
`hexcube.earth` | `earth-green` | Single. | |
`FF6644` | `hexcube.fire` | `fire-orange` |
Single. | | `A9A9A9` | `info` | `slate-gray` |
Single. | | `ADD8E6` | `muse-response` |
`sky-blue` | Single. | | `FFFF00` |
`muse-user` | `gold-yellow` | Single. | |
`9933CC` | `subu.future-major` |
`medium-purple` | Single. | | `660066` |
`subu.future-mega` | `deep-purple` | Single. |
| `CC66FF` | `subu.future-minor` |
`light-purple` | Single. | | `8899FF` |
`subu.glass` | `glass-blue` | Single. | |
`006600` | `subu.major` | `emerald-green` |
Single. | | `004400` | `subu.mega` |
`forest-green` | Single. | | `000000` |
`terminal-background` | `void-black` | Single.
| | `4444FF` | `terminal-blue` |
`terminal-blue` | Single — name happens to
equal Swatch name, coincidence not reuse. | |
`FFFF44` | `terminal-yellow` |
`terminal-yellow` | Single — same
coincidence. | **Count:** 43 Swatches, 35
distinct hex values today (would be 35 Color
rows post-migration, down from 25
invented-name rows — because the `FF0000`
duplicate splits back into the one true bucket
it always should have been, and every "single"
row drops its invented name in favor of its
own hex). ## Color identity naming spec —
OKLCH (the actual identity) Color's identity
is OKLCH, not raw hex — see "The model"
above. Perceptually-accurate math (blending,
tints, contrast checks) is the reason; the
name format is: ``` nnn_nn-n_nnn-nnn_n L C H
``` - **L** — `nnn_nn`, three digits before
the decimal, two after. Covers the full
`000_00`–`100_00` range losslessly (two
digits alone tops out at `99_99`, short of
pure white). - **C** — `n_nnn`, one digit
before the decimal, three after. Covers
`0_000`–`9_999`; real OKLCH chroma never
exceeds ~`0_500`, so this has deliberate
headroom for any wide-gamut/out-of-gamut
value. - **H** — `nnn_n`, three digits
before the decimal, one after (tenth-degree
precision). Range `000_0`–`359_9` — hue is
reduced mod 360 before naming so `360_0` never
appears (it's identical to `000_0`). Example:
`oklch(62.5% 0.130 210)` →
`062_50-0_130-210_0`. Normalization rules
applied before naming (so the same color
always produces exactly one name): 1. Round L
to 2-decimal precision (no clamp needed —
`nnn_nn` already covers the full 0–100 range
exactly; clamping was only a workaround for
the earlier, narrower `nn_nn` draft, now
obsolete). 2. Round C to 3-decimal precision.
3. Reduce H mod 360, then round to 1-decimal
precision. **Rounding here is quantization,
not a digit-count bug.** L, C, and H are
continuous real numbers; any fixed-digit name
format has to truncate somewhere, no matter
how many `n`s you add — more digits shrink
the rounding error, they never eliminate it,
the same tradeoff hex's 8-bit-per-channel
encoding already makes against continuous
light. "Lossless" in this spec means
**idempotent**: the same color always
normalizes to the same name. It does not mean
zero error versus the infinite-precision
source value — that guarantee doesn't exist
for any finite encoding, hex included. This is
the canonical identity — hex is a derived
display value computed from it, not a fallback
or an alternate identity sitting "alongside"
it. ## Locked Color Thing schema Gem
placement, settled: - **onyx** (`Gem::Name`)
— the normalized OKLCH name itself.
Identity. - **sapphire** (`Gem::Kind`) —
`"color"`. - **pearl** (`Gem::Body`) —
free-text description, optional. - **ruby**
(`Gem::Mark`) — classification, not
measurement: which gamut it fits
(`in_srgb_gamut`), which space it's defined in
(`space`), and `ansi` (a categorical
terminal-color label, not a numeric
measurement — moved here from topaz). -
**topaz** (`Gem::Data`) — all numeric
measurements, **as triplets matching the shape
RGB/HSB already use** rather than splitting
L/C/H into separate scalar fields. Accessing
`topaz.l` vs `topaz.lch[0]`/`topaz.lch.0` is
logistically identical for querying or
indexing — bundling costs nothing and
removes the asymmetry of treating LCH
differently from RGB/HSB. - **citrine**
(`Gem::Loc`) — locality, e.g. which
ring/palette grouping it sits in. -
**emerald** (`Gem::Time`) — created/sealed
timestamps, standard. - **amethyst**
(`Gem::Access`) — visibility, standard. -
**diamond** (`Gem::Link`) — reverse edge:
which Swatches currently link to this Color
(not in the live schema today — the only
pointer that exists is the forward
`Swatch.linked_color`; this would be new).
```json { "onyx": "062_50-0_130-210_0",
"sapphire": "color", "pearl": "muted
teal-blue, used for ache/info-class swatches",
"ruby": { "in_srgb_gamut": true, "space":
"oklch", "ansi": "blue" }, "topaz": { "lch":
[62.5, 0.130, 210.0], "hex": "#5FA8C9", "rgb":
[95, 168, 201], "hsb": [197, 53, 79] },
"citrine": { "ring":
"ac1d91a9-ba40-491a-84e1-ce968752c861" },
"emerald": { "created":
"2026-06-25T00:00:00Z", "sealed":
"2026-06-25T00:00:00Z" }, "amethyst": {
"visibility": "public" }, "diamond": {
"swatches": ["info", "ache.unknown"] } } ```
## Migration shape (not yet done) 1. Convert
every Color Thing's current hex to OKLCH,
normalize per the spec above, and rename the
Color Thing's identity to that name (drop the
invented word). 2. Repoint every Swatch's
`linked_color` to the normalized OKLCH name
instead of the invented name. 3. Rebuild every
Color Thing's gem_values to the locked schema
above:
`topaz.lch`/`topaz.hex`/`topaz.rgb`/`topaz.hsb`
as derived measurement triplets,
`ruby.ansi`/`ruby.space`/`ruby.in_srgb_gamut`
as classification — not the old flat
`ruby.{hex,rgb,ansi}` shape
`theme.rs`/`at_routes.rs` read today. 4. Add
`diamond.swatches` as the reverse edge from
Color back to its linking Swatches. 5. Leave
Palette as-is for now — its membership bug
is a separate, later fix. ## Bridge status
(unaffected by this) The CSS bridge built
earlier today (`being-themes.js`
`hexPalette.applyFromPortal()` →
`--swatch-<name>` vars, 5 `--sacred-*` tokens
rewired in `styles.css`) reads Swatch name +
resolved hex from `/api/palette`, not Color's
internal name — so this Color-identity fix
is invisible to the CSS layer. No changes
needed there when this migrates. ## Source
pointers -
`Portal/rust/src/things/theme.rs:18-22`
(`ColorDef`), `:37` (`query_sapphire(pool,
"color")`), `:61` (`query_sapphire(pool,
"swatch")`) -
`Portal/rust/src/server/at_routes.rs:7121-7167`
(color/swatch resolution), `:7174-7193`
(palette metadata lookup — the only place
`:name` actually matters today)
△