Skip to content

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:

js
// 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:

  1. 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).
  2. Amber leak. amber is 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: drop amber from 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 kP(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 separationPerceptionVerdict 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.

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.)

FamilyOKLCH hue centerCatalog 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:

  1. 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).

SeverityClusterCrosses
๐Ÿ”ด severeOrangeโ†’dark-redember terracotta copper campfire (+ amber; campfire/amber share stop1 #fbbf24)
๐Ÿ”ด severeCoral-pinkpeony coral pink (coral/peony share stop1 #fda4af)
๐Ÿ”ด severeChartreusecitron matcha absinthe (citron/matcha share stop1 #bef264)
๐ŸŸ  highBright teal/greengreen mint lagoon jade
๐ŸŸ  highBright blueblue cobalt bioluminescent
๐ŸŸ  highVioletlavender plum purple
๐ŸŸก medDeep winegarnet hibiscus
๐ŸŸก medYellowmarigold turmeric
๐ŸŸก medMagentapurple 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 pick

Degrade 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 โ€‹

  1. Where is venue + active-beacon context known at assign-time? matchColor is currently set client-side in acceptWave() (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.
  2. 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.
  3. OKLCH, not HSL. The catalog's hue field 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 existing hue numbers are a rough guide only; don't hard-code 60ยฐ against them without re-deriving.
  4. Gradients that span families. goldenhour (goldโ†’pink) and purple (violetโ†’pink) straddle family boundaries โ€” assign family by dominant perceived hue (weight the dot + midpoint), and consider tagging cross-family gradients as lower-priority variants.
  5. Drop amber from MATCH_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.


  1. Now (no-regrets, tiny): remove amber from MATCH_COLOR_IDS.
  2. 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.
  3. 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.
  4. Phase 3 (parallel): non-color secondary identification cue for accessibility.

Built with VitePress