COSTA
Technical writeups
Organisational MemoryContext ArchitectureBrief GenerationColour System
LIVE — --:-- — accent #e43a93 — opposite #e86357
SpectrumInterpolationOppositeLightnessSurfacesPre-paintText contrastCode
Colour tool →
Technical overview

The COSTA Colour System

How a six-stop spectral sequence, a quadratic lightness formula, and a synchronous inline script combine to give the interface a colour that shifts continuously across the day.

01 · The foundation

The spectral sequence

The colour system is built on six RGB anchor stops, each pinned to a specific hour of the 24-hour cycle. Every other colour in the interface derives from interpolation over these six values. The anchors were chosen to trace the spectral character of natural ambient light — not with meteorological precision, but with perceptual plausibility.

HourRGBNameEnvironmental reference
00:00
#3730a3
Deep indigoNocturnal sky
07:00
#f43628
Dawn orangeSunrise · low-elevation warm light
10:00
#ee2d37
Morning crimsonMid-morning · Rayleigh scatter clearing
14:00
#d946ef
Noon violetSolar noon · full-spectrum light
18:00
#3b82f6
Afternoon blueLate afternoon · blue hour
21:00
#3730a3
Dusk indigoCivil twilight · night onset

Two properties of this sequence are worth noting. First, the closed cycle: hours 0 and 21 share the exact same RGB value. The transition from 21:00 through midnight to 07:00 interpolates from indigo through deep indigo back to orange-red, which matches how night actually moves toward dawn. There is no discontinuity at midnight.

Second, noon is violet, not white. Scientifically, overhead noon light is broad-spectrum and close to neutral. But chromatic accuracy was not the goal — visual interest and arc were. Violet at 14:00 creates a distinctive midday peak that makes the full day feel like a journey, not a dial between warm and cool.

The stops were calibrated empirically using the colour tool (/colour-tool), which provides a 24-hour scrubber and shows the interpolated spectrum in real time. RGB linear interpolation, rather than a perceptually uniform space like OKLCH, was chosen because these particular anchors produce pleasant intermediate colours under straight channel lerp — and because the implementation simplicity is worth keeping.

02 · The accent colour

Piecewise linear interpolation

For a given hour h ∈ [0, 24), the accent colour is computed by locating the pair of bounding anchor stops and linearly interpolating between them in RGB space:

typescript
function colorAt(h: number): { r: number; g: number; b: number } {
  if (h <= STOPS[0].hour) return STOPS[0]
  if (h >= STOPS[STOPS.length - 1].hour) return STOPS[STOPS.length - 1]

  for (let i = 0; i < STOPS.length - 1; i++) {
    if (h >= STOPS[i].hour && h < STOPS[i + 1].hour) {
      const t = (h - STOPS[i].hour) / (STOPS[i + 1].hour - STOPS[i].hour)
      return {
        r: STOPS[i].r + t * (STOPS[i + 1].r - STOPS[i].r),
        g: STOPS[i].g + t * (STOPS[i + 1].g - STOPS[i].g),
        b: STOPS[i].b + t * (STOPS[i + 1].b - STOPS[i].b),
      }
    }
  }
}

The returned floating-point values are rounded to integer at use time. The parameter h is the local clock hour expressed as a real number — 9.5 for 09:30, 23.75 for 23:45 — so the interpolation is continuous and smooth across the full day.

Drag below to see the interpolated accent at any hour. Notice the segment boundaries at 07:00, 10:00, 14:00, 18:00, and 21:00 — the gradient changes rate at each stop, since each segment has a different slope.

Accent — 09:00
#f03032
H 359° · S 0.86 · L 0.565
03 · The complementary colour

Why a naive complement fails

The wordmark gradient and several highlight elements use a colour that should sit in contrast with the accent. The obvious approach — rotating the accent hue by 180° to find its complement — works on a colour wheel, but produces perceptually wrong results in practice. At dawn, the 180° complement of orange-red is a teal-green that feels out of place. At midnight, the complement of indigo is a muddy yellow.

A narrower rotation of 40° was chosen empirically. This produces a complement that reads as related to the accent — in the same chromatic family — while still creating visible separation. The angular gap decreases slowly from 40° at dawn to 30° by night:

formula
Δθ(h) = 40° − ((h − 7) / 14) × 10°

This function is not clamped. For overnight hours (h < 7), the shift exceeds 40°, increasing the separation slightly as the accent drifts through the deep indigo. At dawn (h = 7) it's exactly 40°; at dusk (h = 21) it's 30°.

The lightness problem

Rotating the hue alone is insufficient. The accent colour at dawn has a lightness of around 0.42. A complement with the same lightness, rendered on a dark background at L ≈ 0.05, has poor contrast — it may not read well as a graphic element. You need the opposite colour to be noticeably lighter than the accent.

But not uniformly lighter. At dawn, the palette should feel warm and cohesive — like a single light source. You do not want a stark contrast pair at 07:00. At midnight, the palette should feel dark and high-contrast. The lightness of the opposite should increase as you move away from dawn in either direction.

This is the problem that the SL dyn ease starlight formula solves.

04 · The formula

SL dyn ease starlight

The name is an acronym: saturation-invariant, lightness-dynamic, quadratic-eased, bidirectional, full diurnal cycle. The formula computes the opposite colour's lightness as a function of both the accent's natural lightness and the hour.

The t parameter

A normalised time parameter t measures distance from dawn in a bidirectional sense. It is zero at dawn (h = 7) and increases toward 1.0 in both directions — toward midnight before dawn, and toward dusk after it:

formula
Pre-dawn (h < 7):   t = max(0,  1 − h / 7)
Post-dawn (h ≥ 7):  t = max(0, min(1, (h − 7) / 11))

The continuity at h = 7 is by construction: the pre-dawn branch evaluates to 1 − 7/7 = 0, and the post-dawn branch evaluates to (7 − 7) / 11 = 0. Both branches join smoothly at dawn, where t = 0.

Key values: t = 1.0 at midnight (h = 0), t = 0.0 at dawn (h = 7), t = 1.0 at dusk (h = 18), t = 1.0 for all hours after 18:00.

The lightness formula

formula
L_o = L_a + t² × (0.88 − L_a)

At t = 0: L_o = L_a — the opposite matches the accent's natural lightness. At t = 1: L_o = 0.88, regardless of the accent. Between those extremes, the quadratic factor t² keeps the opposite close to the accent through most of the working day and concentrates the lightening toward evening and overnight.

Why t² rather than t? A linear parameter would start brightening the opposite immediately after dawn, giving the noon palette a noticeably lighter opposite than the morning. The quadratic curve keeps the opposite naturalistically close to the accent through midday — when the interface is at its most vibrant and the accent itself is lighter — and then brightens more abruptly toward the high-contrast night aesthetic.

The 0.88 ceiling

The maximum lightness is 0.88, not 1.0. Pure white (L = 1.0) would lose all chromatic identity — it could be any hue. At L = 0.88, the opposite colour retains its hue and saturation while achieving approximately 4.5:1 contrast against the base background at L = 0.05, which is the WCAG AA threshold for normal text. The value was verified against all hues present in the accent sequence; 0.88 is the minimum lightness that consistently passes across all of them.

Saturation is preserved

S_o = S_a. The opposite colour inherits the accent's saturation exactly. This keeps the two in the same chromatic family — the wordmark gradient reads as a coherent colour, not a desaturated grey.

The complete opposite colour computation, in production code:

typescript
function oppositeAt(h: number) {
  const acc = colorAt(h)
  const { h: Ha, s: Sa, l: La } = rgbToHsl(acc.r, acc.g, acc.b)

  const shift = 40 - ((h - 7) / (21 - 7)) * 10
  const t = h < 7
    ? Math.max(0, 1 - h / 7)
    : Math.max(0, Math.min(1, (h - 7) / 11))

  return hslToRgb(
    (Ha + shift + 360) % 360,  // rotated hue
    Sa,                         // preserved saturation
    La + t * t * (0.88 - La),  // eased lightness
  )
}

Drag the slider to explore the formula across all 24 hours. At dawn (h = 7), accent and opposite are nearly identical in lightness. At midnight and in the evening, the opposite brightens toward 0.88 while the accent stays at its natural level.

Accent — 14:00
#d946ef
H 292° · S 0.84 · L 0.606
Opposite — Δθ 35.0°
#f47abc
H 328° · S 0.85 · L 0.718
t0.636
0.405
Δθ35.0°
L_a0.606
L_o0.718
L_o − L_a0.112
Lightness over 24 hours
0.880.000.250.500.751.0000071014182124dawnduskL_a (accent)L_o (opposite)
L_a (faint) is the accent's natural lightness. L_o (bright) shows the eased opposite lightness. The dashed line at 0.88 is the ceiling — the maximum L_o ever reaches. Both converge at dawn (h = 7) where t = 0.
05 · The surface system

Deriving everything else from accent hue

Once the accent hue is known, all background and border colours are computed from it using fixed saturation and lightness parameters. No independent colour specification is needed for each surface role — the entire interface shares a single hue that shifts with the hour, and the surface system scales with it.

Each surface is computed as hslToRgb(H_a, s_role, l_role) where H_a is the accent hue and the saturation and lightness are predetermined per role:

PropertySLRole
--bg0.280.05Base background — near-black, hue-tinted
--bg-surface0.380.08Cards, panels
--bg-elevated0.350.11Modals, popovers
--border-subtle0.340.14Subdued secondary borders
--border0.280.24Default borders

Adjacent layers differ by ΔL ≈ 0.03–0.05 — enough perceptual separation to distinguish layered surfaces without disrupting the chromatic coherence. The saturation values vary slightly by role: surface layers use slightly higher saturation (0.38) than the base background (0.28) to ensure they read as distinctly coloured rather than slightly grey. Borders use lower lightness saturation to remain visually recessive.

Surface derivation
H = 292°14:00
--bg
S 0.28 L 0.05
--bg-surface
S 0.38 L 0.08
--bg-elevated
S 0.35 L 0.11
--border-subtle
S 0.34 L 0.14
--border
S 0.28 L 0.24
06 · The rendering mechanic

Pre-paint injection and FOUC elimination

Writing computed colour values to the DOM in client-side JavaScript after page load produces a flash-of-unstyled-content: the browser renders one frame with the CSS default values before the script runs, then jumps to the computed theme. When the computed theme is dramatically different from the default — as it is here, where the default is a generic purple and the computed value is midnight indigo at 23:00 — this flash is very visible.

The solution is to move the colour computation into a synchronous inline <script> in the HTML <head>, placed before any stylesheets or body content. Browsers execute inline scripts synchronously during HTML parsing — the parser halts, the script runs to completion, and its DOM writes are committed before the browser constructs the render tree or performs any paint. The page's first frame contains the correct colours.

The transition suppression problem

The CSS :root uses registered @property declarations for each custom property, which enables CSS transitions. If a CSS transition is active when the inline script writes new values, the browser will animate from the @property initial-value to the computed value on first paint — a brief but visible animation from the wrong colour to the right one.

The solution is a double requestAnimationFrame callback:

javascript
// In the inline <script>:

// 1. Suppress transitions before writing
el.style.setProperty('transition', 'none')

// 2. Write all custom properties (accent, opposite, backgrounds, borders...)
el.style.setProperty('--accent', 'rgb(244, 54, 40)')
// ... all other properties ...

// 3. Restore transitions after two frames
requestAnimationFrame(() =>
  requestAnimationFrame(() =>
    el.style.removeProperty('transition')
  )
)

Two frames are required because the first rAF fires before the browser has committed the paint with transition suppressed. The second fires after that paint has completed, at which point restoring transitions will not cause any animation of the already-committed values. Subsequent JavaScript updates to the custom properties will then animate normally.

Static deployment compatibility

Because the inline script reads the client's local time at execution, no server-side knowledge of the client time zone is required. The approach works with fully static deploys — no cookies, no edge personalisation, no additional request. The landing page uses a fixed h = 7 (dawn) for the pre-paint pass to present a consistent chromatic snapshot to all visitors, then runs a live animation cycle in JavaScript to demonstrate the full spectrum.

Real-time updates

After the initial paint, a periodic timer running at 60-second intervals re-evaluates the colour computation and updates the CSS custom properties in place. Because @property custom properties are tracked by the CSS transition system, the 1-second linear transition on :root smoothly interpolates between successive one-minute colour values. No React re-renders, no DOM traversal — just setProperty and CSS handles the animation.

07 · Accessibility detail

The text-accent contrast floor

The accent colour at overnight hours (21:00–07:00) has a natural lightness of around 0.40–0.42. Used directly as a text colour on the dark background at L = 0.05, this fails the WCAG AA contrast threshold of 4.5:1 by a material margin. The wordmark gradient and large display uses the opposite colour (which is already high-L at night), but inline text rendered in the accent colour — time stamps, active state labels, keyboard shortcuts — needs a separate solution.

A CSS custom property --text-accent enforces a minimum lightness floor while preserving the accent hue and saturation:

typescript
// Computed alongside the other properties in applyColorsAt():
const { h: taH, s: taS, l: taL } = rgbToHsl(r, g, b)
el.style.setProperty('--text-accent', hslToHex(taH, taS, Math.max(taL, 0.62)))

The floor of 0.62 is the minimum lightness that achieves approximately 4.5:1 contrast against the dark background surface across all hues in the accent sequence. Below L = 0.62, some hues fall below the threshold; above it, all pass. At most daytime hours, --text-accent equals --accent exactly — the floor only activates during the overnight indigo period.

This means the --text-accent variable can safely replace --accent everywhere text colour is needed on dark backgrounds, without affecting the daytime appearance.

08 · Reference

Implementation notes

The full production implementation spans three locations:

web/app/layout.tsx
Inline synchronous pre-paint script in <head>. Runs before any CSS or body content. Contains the full colour computation in minified form, the transition suppression, and the double-rAF restore.
web/app/landing/page.tsx
applyColorsAt(h) — the live update function called every 50ms during the demo animation, every 60 seconds in real-time mode. Mirrors the layout.tsx script exactly but with named variables for readability.
web/app/page.tsx
applyTimeColor() — the equivalent function for the application pages. Same formula, called every 60 seconds via setInterval.

All three share the same formula constants: the six STOPS array, the hue shift function, the bidirectional t parameter, and the 0.88 lightness ceiling. Any change to the formula needs to be propagated to all three locations.

The interactive calibration environment at /colour-tool provides a 24-hour spectrum view, per-hour opposite colour inspection, and a draggable chart for manual tuning of the opposite colour at each hour. The manual-colour-ramp.txt file in the project root records a reference calibration run that informed the final formula constants.

Colour tool →Interactive 24h spectrum and formula explorer
Also in this series
Organisational MemoryContext ArchitectureBrief Generation
COSTA · Colour System← Back to app