Scheduled night dimming on macOS, and the trap that walks your screen to black

Dimming a display on a schedule sounds like a two-line feature: check the clock, set the brightness. The trap is in the word set. If the value you apply is derived from the value that is already there, then every check makes the screen a little darker — and by morning it is black.

The feature, as users see it

A day is split into windows — a work window, an evening window, a night window — and each one carries its own white point and brightness. Nothing is global; changing what happens at 21:00 does not disturb what happens at 14:00.

Times are stored as minutes since local midnight. That has a pleasant consequence: an end time earlier than the start time simply wraps past midnight, so "night, 20:00 to 08:00" needs no special case at all. It is the same arithmetic as any other window.

Trap 1 — the multiplier that compounds

Here is the mistake that looks completely reasonable:

let current = display.brightness
let dimmed  = current * 0.6      // wrong
apply(dimmed)

Run that on a 60-second heartbeat and the panel is multiplied by 0.6 over and over: 0.6, 0.36, 0.22, 0.13 … The display walks itself to black, and the user reports that the app "breaks my screen overnight".

The fix is to make the value absolute — a fraction of full scale, not a multiple of the live value:

let target = 0.6               // fraction of full scale
apply(target)

Now applying it twice is the same as applying it once. In one word: the scheduler is idempotent. A scheduler whose repeated evaluation is not a no-op is not idempotent — and it will drift.

Trap 2 — the heartbeat that stops while you drag a slider

The second trap is quieter. A timer scheduled on the default run-loop mode does not fire while the user is dragging a slider, because the drag holds the run loop in the event-tracking mode. So the check that was supposed to land at 21:00 quietly does not.

Registering the heartbeat in .common mode instead means it still lands while the user is interacting — which is exactly when they are most likely to notice.

Trap 3 — a fade that restarts itself

Once the value is absolute, a second question follows: what happens when the scheduler decides on the same band twice? If each decision restarts the fade, the screen visibly pulses every minute. The answer is that a band already in force is a no-op, so the heartbeat never restarts a transition that is already heading to the right place. There is a separate "apply now" path for when the user explicitly asks.

And it has to be reversible

Turning the schedule off hands the display back to the state it held before the schedule was on — not to some default. If the user had set 80%, they get 80% back, not 100%.

Two smaller decisions worth naming

Honest limits

Related

All guides · How to change external display brightness · Colours look washed out · DDC/CI not responding