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
- The fade is tuned to be long enough to read as a transition, short enough not to feel sluggish — a real trade-off, stated rather than hidden.
- Breaks (a pomodoro-style timer) can dim or overlay the screen. The state machine has a one-click skip that ends the break early and returns to work, because a break you cannot skip is a break people disable entirely.
Honest limits
- This dims the picture, not the backlight. At low levels blacks stop being true black.
- Changing the white point is software warmth, not hardware calibration. For accurate colour you need a colorimeter.
- It is the same mechanism class as Night Shift and f.lux — we are not claiming anything more advanced than that.
Related
All guides · How to change external display brightness · Colours look washed out · DDC/CI not responding