Beacon Color Rotation โ venue-aware assignment (design) โ
Status: Design proposal โ not yet implemented. Author: drafted with Claude Code, 2026-06-12. Related: BEACON_COLORS.md (reference โ currently stale), WAVE_TO_MEET.md, packages/ui/colors.js.
Problem โ
When two users accept a wave, both phones display the same color beacon so they can find each other in person. The color is the entire identity signal across a room.
Today the assignment is uniform-random over the full 40-color catalog, with no venue-awareness:
// packages/ui/colors.js
export const MATCH_COLOR_IDS = Object.keys(COLORS) // all 40
export function randomMatchColorId() {
return MATCH_COLOR_IDS[Math.floor(Math.random() * MATCH_COLOR_IDS.length)]
}Two consequences:
- Crossed colors. Nothing stops two pairs meeting in the same venue from getting the same โ or a perceptually indistinguishable โ beacon. "Is that orange lantern mine or theirs?" The catalog has many near-duplicate beacons (four orangeโdark-red colors, three coral-pinks, three chartreuses โ see the cluster table below).
- Amber leak.
amberis in the pool even though it's the reserved brand color ("amber is sacred โ it's the lantern itself"). A connection can render as the generic Lantern beacon. Fix regardless of approach: dropamberfrom the pool.
Why "just curate a distinct subset" isn't enough โ
Even a hand-picked set of, say, 18 maximally-distinct colors still hits the birthday paradox under random assignment. Probability that โฅ2 of k simultaneously-glowing beacons in one venue collide (exact id, N = 18):
| active beacons k | P(collision) |
|---|---|
| 3 | ~16% |
| 4 | ~30% |
| 5 | ~46% |
Near-collisions (same perceptual cluster, not same id) push these higher. A subset makes clashes rarer; only venue-aware assignment guarantees co-present beacons stay distinct.
The model: anchor families + within-family variants โ
This is the two-level structure from the design discussion ("4 categorically different hues that are rotatedโฆ and then different color combinations within that category").
- Level 1 โ anchor families. Partition the hue wheel into a few well-separated families. Assignment rotates across families so co-present beacons land in categorically different colors (blue vs orange vs purple vs green โ unmistakable at a glance).
- Level 2 โ within-family variants. Each family holds several distinct gradient treatments. Within a chosen family, pick a variant that's also distinct (by lightness/chroma) from any same-family beacon already active. This preserves variety so a given family doesn't always look like the exact same beacon.
How much separation is "comfortable"? โ
Reasoning in a perceptually uniform space (OKLCH hue), not raw HSL โ see the caveat below โ the practical thresholds for a glanceable beacon across a dim room, with no side-by-side comparison:
| OKLCH hue separation | Perception | Verdict for beacons |
|---|---|---|
| < 30ยฐ | Often share a color name | โ crossed |
| 30โ60ยฐ | Distinguishable side-by-side, risky at a glance | โ ๏ธ marginal |
| โฅ 60ยฐ | Different basic color terms (red/orange/yellow/green/blue/purple) | โ safe at a glance |
| 90ยฐ+ | Obviously, categorically different | โ โ very safe |
So the comfortable target is โฅ 60ยฐ of hue separation between any two simultaneously-active beacons. 60ยฐ is one full step on the six basic-color-term wheel, which is exactly the granularity at which people switch color words โ the right bar for "no one is confused about which lantern is theirs."
A secondary axis rescues the rare case where two beacons must sit closer than 60ยฐ (a busy venue exhausts the families): a large lightness gap (pale vs deep โ e.g. rosewater vs garnet, both ~hue 350) keeps them tellable apart even at similar hue.
Recommended partition: 6 families @ 60ยฐ โ
Six families matches the basic color terms, is reliably nameable, and gives more headroom than four for busy venues. (Your 4-family / 90ยฐ version is the simpler, even-safer-contrast variant โ see "Alternative" below.)
| Family | OKLCH hue center | Catalog members (variants) |
|---|---|---|
| Red / Pink | ~10ยฐ | garnet, hibiscus, pink, peony, coral, rosewater |
| Orange / Amber | ~40ยฐ | ember, terracotta, copper, campfire, dawn, goldenhour, turmeric ยท (amber reserved) |
| Yellow / Lime | ~90ยฐ | marigold, citron, matcha, absinthe |
| Green / Teal | ~155ยฐ | spruce, jade, sage, green, mint, lagoon |
| Blue / Cyan | ~215ยฐ | bioluminescent, cobalt, fog, blue, pearl, midnight, indigo |
| Purple / Magenta | ~280ยฐ | twilight, lavender, lilac, purple, plum, neon, aubergine |
With 6 families, up to 6 simultaneous beacons are guaranteed categorically different. The 7th reuses the least-recently-used family but takes a lightness-separated variant, and is still โฅ 60ยฐ from its nearest other-family neighbor.
Alternative: 4 families @ 90ยฐ (your "Blue โ Orange โ Purple โ Green") โ
Collapse the six into four by merging adjacent pairs โ maximum contrast, simplest to reason about, but only 4 simultaneous beacons before a family repeats:
- Warm (RedโOrange) ยท 2. YellowโGreen ยท 3. CyanโBlue ยท 4. PurpleโMagenta
Recommendation: ship 6 @ 60ยฐ unless real venue data shows โค4 beacons are ever simultaneously active, in which case 4 @ 90ยฐ is cleaner.
Perceptual cluster table (why specific colors cross today) โ
Grouped by perceived beacon appearance (the stop1โstop2 gradient + dot glow, not raw hue โ lightness separates same-hue colors, e.g. pale rosewater vs deep garnet).
| Severity | Cluster | Crosses |
|---|---|---|
| ๐ด severe | Orangeโdark-red | ember terracotta copper campfire (+ amber; campfire/amber share stop1 #fbbf24) |
| ๐ด severe | Coral-pink | peony coral pink (coral/peony share stop1 #fda4af) |
| ๐ด severe | Chartreuse | citron matcha absinthe (citron/matcha share stop1 #bef264) |
| ๐ high | Bright teal/green | green mint lagoon jade |
| ๐ high | Bright blue | blue cobalt bioluminescent |
| ๐ high | Violet | lavender plum purple |
| ๐ก med | Deep wine | garnet hibiscus |
| ๐ก med | Yellow | marigold turmeric |
| ๐ก med | Magenta | purple neon |
Safe / distinct (read as their own beacon โ note these are the desaturated and dark colors): obsidian midnight espresso aubergine twilight fog pearlsage lilac rosewater dawn spruce indigo goldenhour.
Assignment algorithm (sketch) โ
assignMatchColor(venueId, now):
active = connections in venueId whose beacon is currently displayed
(within the active/glow window, not expired)
takenColors = active.map(c => c.matchColor)
takenFamilies = takenColors.map(family)
# Level 1 โ pick the least-recently-used family not currently active
family = leastRecentlyUsed(FAMILIES \ takenFamilies)
or, if all families active: the family whose nearest active hue
is farthest away (maximize min hue distance)
# Level 2 โ pick a within-family variant distinct from same-family actives
candidates = family.variants \ takenColors
pick the candidate maximizing min(ฮE) to every taken color
(ฮE = OKLCH perceptual distance on the dominant stop + dot)
# Hard guarantee
assert minHueDistance(pick, takenColors) >= 60ยฐ
OR lightnessGap(pick, nearestTaken) >= L_THRESHOLD
return pickDegrade gracefully: if the venue is so busy that even the best pick violates the 60ยฐ/lightness guard, take the maximal-min-distance color and log it โ don't fail the match. (Surface as a metric: "venue exhausted color space" is a signal we need more distinct families, or a non-color secondary cue โ see Accessibility.)
Open questions for implementation โ
- Where is venue + active-beacon context known at assign-time?
matchColoris currently set client-side inacceptWave()(waveService.js) with no venue/active-set in scope. Venue-aware assignment almost certainly has to move server-side (the wave/connection service) where the set of active connections per venue is queryable. This is the main lift. - What is the "active beacon" window? Connections expire after 4h (
CONNECTION_EXPIRY_MS), but a beacon is only displayed during the meet-up. Define the window that counts toward collision avoidance (probably "beacon opened in the last N minutes," not the full 4h connection life) so we don't exhaust the palette on stale connections. - OKLCH, not HSL. The catalog's
huefield is labeled "OKLCH/HSL" but the two differ by ~20ยฐ+ for blues. Before encoding thresholds, recompute hue / lightness / chroma in OKLCH from each color's dominant stop (+dot). The existinghuenumbers are a rough guide only; don't hard-code 60ยฐ against them without re-deriving. - Gradients that span families.
goldenhour(goldโpink) andpurple(violetโpink) straddle family boundaries โ assign family by dominant perceived hue (weight thedot+ midpoint), and consider tagging cross-family gradients as lower-priority variants. - Drop
amberfromMATCH_COLOR_IDSโ no-regrets, do this independently of the rest.
Accessibility (carried from BEACON_COLORS.md, still open) โ
Color is the only matching signal today. Even with perfect hue separation, ~8% of men have a form of color-vision deficiency, and adjacent families (red/green, blue/purple) are exactly the confusable axes for them. The anchor model helps (categorical separation is more CVD-robust than fine hue distinctions) but doesn't solve it. Layer a non-color secondary cue โ distinct pulse rhythm, an icon/shape overlay, or a short code โ so identification never depends on hue alone.
Recommended phasing โ
- Now (no-regrets, tiny): remove
amberfromMATCH_COLOR_IDS. - Phase 1: encode the 6 anchor families + variants (OKLCH-derived) in
packages/ui/colors.js; add a Storybook story rendering families as rows so design can eyeball cross-family contrast. - Phase 2: move assignment server-side; implement venue-aware family rotation + within-family max-distance pick with the 60ยฐ/lightness guard and the "exhausted color space" metric.
- Phase 3 (parallel): non-color secondary identification cue for accessibility.