Blend colours across an event's phases, day by day (#74) #128

Merged
claude-bot merged 1 commit from feat/74-hsv-interpolation into main 2026-09-05 00:29:26 +00:00
Contributor

Closes #74. Implements spec §4.2.

Opt in per event with phase_config.interpolate and a 26-day Christmas fades from its opening colours through the mid ones to the closing ones, instead of stepping between three fixed looks.

The design question is not the colour maths

It's what a human approves. Interpolation wants a scheme per day; the review queue wants a scheme per phase. Twenty-six independently approvable rows for one holiday would make the queue unusable.

So the four phase schemes stay the anchors — what the UI lists, what a person edits and approves — and the per-day rows are derived: generated from the anchors, rebuilt whenever an anchor changes, carrying schemes.derived_from, and filtered out of the review list, WLED preset sync and config export. Anchors sit at the centre of their phase rather than its edge, so a phase's colours are actually seen during that phase rather than being already half-faded on day two.

A derived day is approved only when every anchor it blends is approved. Falling back to the plain anchor colours on a half-approved event would light the tree in something the user never signed off on because a different scheme was approved. Approval means a human vetted what shows, so a half-approved event stays dark on its blended days exactly as an unapproved one does.

Colour maths

Hue takes the shorter arc. Red → magenta is 60° backwards; a linear blend takes the 300° route forwards and spends a fortnight passing through green. Exactly 180° apart is genuinely ambiguous, so forward is chosen and pinned by a test — otherwise the output depends on floating-point noise.

Blending is in HSV, not channel-wise RGB: the RGB midpoint of red and blue is a muddy (128, 0, 128) rather than full-saturation magenta. On a strip that difference is the whole point of §4.2.

An achromatic endpoint contributes no hue. colorsys reports white as hue 0, which reads as "red" — so a naive blend from blue to white swings towards red and lands on a lilac nobody asked for. The other endpoint's hue holds instead.

effect_id and palette do not interpolate. Effect 26 is not halfway between 12 and 40, it is an unrelated animation; lerping them walks the strip through effects nobody chose. They step to the nearer anchor at the midpoint. Days landing exactly on an anchor get no row at all — the blend there is the anchor — so a three-day event produces zero derived rows, which is correct: there is nothing between the anchors to interpolate.

Two exclusions that are load-bearing, not tidiness

  • _find_approved_for_key matches event_key + phase + approved, and a derived row satisfies all three: it carries the phase of the day it falls in and inherits approval. Without the filter, next year's mid anchor would be seeded from whichever blended day the query happened to return first — a colour halfway between two anchors that nobody chose.
  • Preset sync would claim a WLED slot per day and overflow PRESET_COUNT on a single holiday. Presets mirror the schemes for manual recall from the WLED app; the scheduler pushes state directly and never reads them back, so the blends lose nothing by staying out of the slot budget.

The UI now tells the truth about a blended day

The side panel previewed the anchor, which on an interpolated day is a colour that never reaches the controller — the same class of mistake as #93, where the calendar displayed a scheme the scheduler wasn't pushing. New GET /schemes/for-date/{date} returns what will actually light, resolving through the same helper the scheduler uses, so the preview and the push cannot disagree. Approve/edit still target the anchor.

Writing the tests found a real bug

sync_derived_schemes originally read anchor colours off scheme.segments. But generate_schemes_for_event rebuilds those rows with db.add(SchemeSegment(...)) rather than through the relationship, so the loaded collection can still hold the previous generation's values. Blending from those would produce a gradient between colours no longer anchored anywhere — and it would look entirely plausible. It now queries the rows directly.

A second, smaller correction: three of my own tests were passing because of that staleness. They painted an anchor and then called the full generator, which regenerates unapproved anchors from the template and throws the edit away — the stale collection was preserving it. They now go through resync_derived_for_event_id, which is what PUT /schemes/{id} actually calls.

Tests

59 new backend tests, written against properties a plausible-but-wrong implementation fails. The one that matters most is test_the_gradient_actually_moves: it walks all fifteen days of an event and asserts the hue is strictly increasing, because a "blend" that returned either anchor unchanged would satisfy every per-day spot check.

Verification

Backend ruff clean, mypy clean, 654 tests passing. alembic upgrade head + alembic check clean on a fresh DB, and the downgrade tested (it deletes derived rows before dropping the column, rather than leaving a downgraded install worse than one that never interpolated). Frontend in node:22: tsc -b clean, eslint clean, 81 vitest passing, npm run build succeeds.

🤖 Generated with Claude Code

Closes #74. Implements spec §4.2. Opt in per event with `phase_config.interpolate` and a 26-day Christmas fades from its opening colours through the mid ones to the closing ones, instead of stepping between three fixed looks. ## The design question is not the colour maths It's **what a human approves.** Interpolation wants a scheme per day; the review queue wants a scheme per phase. Twenty-six independently approvable rows for one holiday would make the queue unusable. So the four phase schemes stay the **anchors** — what the UI lists, what a person edits and approves — and the per-day rows are **derived**: generated from the anchors, rebuilt whenever an anchor changes, carrying `schemes.derived_from`, and filtered out of the review list, WLED preset sync and config export. Anchors sit at the *centre* of their phase rather than its edge, so a phase's colours are actually seen during that phase rather than being already half-faded on day two. **A derived day is approved only when every anchor it blends is approved.** Falling back to the plain anchor colours on a half-approved event would light the tree in something the user never signed off on because a *different* scheme was approved. Approval means a human vetted what shows, so a half-approved event stays dark on its blended days exactly as an unapproved one does. ## Colour maths Hue takes the **shorter arc**. Red → magenta is 60° backwards; a linear blend takes the 300° route forwards and spends a fortnight passing through green. Exactly 180° apart is genuinely ambiguous, so forward is chosen and pinned by a test — otherwise the output depends on floating-point noise. Blending is in **HSV, not channel-wise RGB**: the RGB midpoint of red and blue is a muddy `(128, 0, 128)` rather than full-saturation magenta. On a strip that difference is the whole point of §4.2. An **achromatic endpoint contributes no hue**. `colorsys` reports white as hue 0, which reads as "red" — so a naive blend from blue to white swings towards red and lands on a lilac nobody asked for. The other endpoint's hue holds instead. **`effect_id` and `palette` do not interpolate.** Effect 26 is not halfway between 12 and 40, it is an unrelated animation; lerping them walks the strip through effects nobody chose. They step to the nearer anchor at the midpoint. Days landing exactly on an anchor get no row at all — the blend there *is* the anchor — so a three-day event produces zero derived rows, which is correct: there is nothing between the anchors to interpolate. ## Two exclusions that are load-bearing, not tidiness - `_find_approved_for_key` matches `event_key + phase + approved`, and a derived row satisfies all three: it carries the phase of the day it falls in and inherits approval. Without the filter, next year's `mid` anchor would be seeded from whichever blended day the query happened to return first — a colour halfway between two anchors that nobody chose. - **Preset sync** would claim a WLED slot per day and overflow `PRESET_COUNT` on a single holiday. Presets mirror the schemes for manual recall from the WLED app; the scheduler pushes state directly and never reads them back, so the blends lose nothing by staying out of the slot budget. ## The UI now tells the truth about a blended day The side panel previewed the anchor, which on an interpolated day is a colour that never reaches the controller — the same class of mistake as #93, where the calendar displayed a scheme the scheduler wasn't pushing. New `GET /schemes/for-date/{date}` returns what will actually light, resolving through the **same helper the scheduler uses**, so the preview and the push cannot disagree. Approve/edit still target the anchor. ## Writing the tests found a real bug `sync_derived_schemes` originally read anchor colours off `scheme.segments`. But `generate_schemes_for_event` rebuilds those rows with `db.add(SchemeSegment(...))` rather than through the relationship, so the loaded collection can still hold the **previous generation's** values. Blending from those would produce a gradient between colours no longer anchored anywhere — and it would look entirely plausible. It now queries the rows directly. A second, smaller correction: three of my own tests were passing *because* of that staleness. They painted an anchor and then called the full generator, which regenerates unapproved anchors from the template and throws the edit away — the stale collection was preserving it. They now go through `resync_derived_for_event_id`, which is what `PUT /schemes/{id}` actually calls. ## Tests 59 new backend tests, written against properties a plausible-but-wrong implementation fails. The one that matters most is `test_the_gradient_actually_moves`: it walks all fifteen days of an event and asserts the hue is *strictly* increasing, because a "blend" that returned either anchor unchanged would satisfy every per-day spot check. ## Verification Backend `ruff` clean, `mypy` clean, **654 tests passing**. `alembic upgrade head` + `alembic check` clean on a fresh DB, and the downgrade tested (it deletes derived rows before dropping the column, rather than leaving a downgraded install worse than one that never interpolated). Frontend in `node:22`: `tsc -b` clean, `eslint` clean, **81 vitest passing**, `npm run build` succeeds. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blend colours across an event's phases, day by day (#74)
Some checks failed
CI / Pre-commit hooks (pull_request) Successful in 43s
CI / Dockerfile lint (pull_request) Successful in 11s
CI / Alembic migration check (pull_request) Successful in 1m16s
CI / Docker build, health smoke & E2E (pull_request) Has been cancelled
CI / Python tests (pull_request) Has been cancelled
CI / Frontend lint, test & build (pull_request) Has been cancelled
CI / Python lint & type-check (pull_request) Has been cancelled
eb024d8d9d
Opt in per event with phase_config.interpolate and a 26-day Christmas
fades from its opening colours through the mid ones to the closing ones
instead of stepping between three fixed looks.

The design question this feature actually poses is not the colour maths,
it is what a human approves. Interpolation wants a scheme per day; the
review queue wants a scheme per phase. Twenty-six approvable rows for one
holiday would make the queue unusable, so the four phase schemes stay the
anchors -- what the UI lists, what a person edits and approves -- and the
per-day rows are derived from them: rebuilt whenever an anchor changes,
carrying derived_from, and filtered out of the review list, WLED preset
sync and config export. Anchors sit at the centre of their phase rather
than its edge, so a phase's colours are actually seen during it.

A derived day is approved only when every anchor it blends is approved.
Falling back to the plain anchor colours on a half-approved event would
light the tree in something the user never signed off on because a
different scheme was approved, and approval means a human vetted what
shows.

Hue takes the shorter arc of the wheel. Red to magenta is 60 degrees
backwards; a linear blend takes the 300-degree route forwards and spends
a fortnight passing through green. Blending is in HSV rather than
channel-wise RGB because the RGB midpoint of red and blue is a muddy
(128, 0, 128) instead of full-saturation magenta. An achromatic endpoint
contributes no hue -- colorsys reports white as hue 0, which reads as red
-- so blending towards white desaturates along the other colour's hue.

effect_id and palette do not interpolate. Effect 26 is not halfway
between 12 and 40, it is an unrelated animation, so they step to the
nearer anchor at the midpoint. Days landing exactly on an anchor get no
row at all: the blend there is the anchor, and a three-day event
therefore produces zero derived rows, which is correct.

Two exclusions are load-bearing rather than tidiness. _find_approved_for_key
matches event_key + phase + approved, and a derived row satisfies all
three -- without the filter, next year's mid anchor would be seeded from
whichever blended day the query returned first. And preset sync would
claim a WLED slot per day, overflowing PRESET_COUNT on a single holiday.

The side panel now previews the day's actual colours rather than the
anchor's, through a new GET /schemes/for-date/{date} that resolves via
the same helper the scheduler uses -- so the preview and the push cannot
disagree, which is the drift #93 fixed once already.

Writing the tests found a real bug: sync_derived_schemes originally read
anchor colours off scheme.segments, but generate_schemes_for_event
rebuilds those rows with db.add(SchemeSegment(...)) rather than through
the relationship, so the loaded collection can still hold the previous
generation's values. Blending from those would have produced a gradient
between colours no longer anchored anywhere, and it would have looked
entirely plausible. It now queries the rows directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rbrooks force-pushed feat/74-hsv-interpolation from eb024d8d9d
Some checks failed
CI / Pre-commit hooks (pull_request) Successful in 43s
CI / Dockerfile lint (pull_request) Successful in 11s
CI / Alembic migration check (pull_request) Successful in 1m16s
CI / Docker build, health smoke & E2E (pull_request) Has been cancelled
CI / Python tests (pull_request) Has been cancelled
CI / Frontend lint, test & build (pull_request) Has been cancelled
CI / Python lint & type-check (pull_request) Has been cancelled
to bba8707f15
All checks were successful
CI / Pre-commit hooks (pull_request) Successful in 1m57s
CI / Python lint & type-check (pull_request) Successful in 1m23s
CI / Alembic migration check (pull_request) Successful in 38s
CI / Dockerfile lint (pull_request) Successful in 12s
CI / Frontend lint, test & build (pull_request) Successful in 1m19s
CI / Python tests (pull_request) Successful in 4m55s
CI / Docker build, health smoke & E2E (pull_request) Successful in 3m5s
2026-09-05 00:15:53 +00:00
Compare
claude-bot deleted branch feat/74-hsv-interpolation 2026-09-05 00:29:26 +00:00
Sign in to join this conversation.
No description provided.