One dim level drives both mechanisms, in a fixed order
Context
Gloam subtracts light two ways, and CONTEXT.md has always insisted they are different things: the
backlight is Android’s value, the shade is ours. Phase 1 has to decide whether the user sees
that distinction, because CONTEXT.md also says the dim level is “the single number the user moves.
The one value the product is about.” Those two sentences are in tension the moment the backlight is
actually driven.
Measured on the phone (Redmi amethyst, HyperOS, 2026-08-30):
WindowManager.LayoutParams.screenBrightnesson the shade window takes the panel from 500 nits to 6.64 nits, and costs no permission at all.- The display’s own floor is 2.0 nits. The system reverts the override when the window goes away, so a ROM kill cannot strand anyone dark.
- The user’s own brightness slider goes inert while the override is live — the stored value moves, the applied brightness does not, and whatever they set lands the moment the shade comes off.
So the two mechanisms are asymmetric in range (a factor of ~75 versus the last stretch below the floor) and asymmetric in side effects (one makes a system control appear broken; the other does not).
Decision
One dim level, one ramp, in a fixed order. The first stretch of the slider walks the backlight
down from wherever the user left it to MIN_BACKLIGHT; the remainder raises the shade from fully
transparent to MAX_SHADE_ALPHA.
The backlight half gets a toggle, not a second slider — “also lower the screen brightness”, defaulting on — for the user who would rather keep their system slider live.
MIN_BACKLIGHT is a safety constant, not a preference, exactly like MAX_SHADE_ALPHA and for
the same reason read from the other end: LayoutParams.screenBrightness documents 0.0f as
BRIGHTNESS_OVERRIDE_OFF, which turns the backlight off. A ramp that reaches zero hands the user a
black screen with a live touchscreen — the precise failure MAX_SHADE_ALPHA exists to prevent,
arrived at from the opposite direction.
100 means “as dark as currently allowed”, not a fixed quantity of light. Phase 2’s ultra dark therefore extends the top of the same slider rather than adding a second control, and the number does not jump when it is enabled.
Alternatives
Two controls — a dim level and a backlight slider. The honest case for it is that these really
are different mechanisms with different failure modes, and CONTEXT.md keeps them apart everywhere
else. It loses because users arrive with one question, and the answer to “how dark?” should not
require understanding which of two subsystems is currently doing the work.
Both mechanisms moving together across the whole range. Rejected because sequencing buys something concrete: at moderate dim levels the shade is fully transparent, so there is no overlay over the user’s content at all and colours are untouched.
Settings.System.SCREEN_BRIGHTNESS via WRITE_SETTINGS. Rejected on the measurement: it mutates
the phone’s saved brightness, so a ROM kill strands the user at minimum brightness with no Gloam
alive to restore it. The window override has no such failure mode and needs no permission.
Consequences
CONTEXT.md’s definition of dim level survives unamended. The vocabulary keepsbacklightas Android’s value; it is now the first half of the ramp rather than a word nothing reads.- The user’s brightness slider is deferred, not broken, while Gloam runs. That is inherent to the mechanism rather than a defect introduced here, and it is a thing the UI has to say out loud.
- The shade window’s
screenBrightnessis now load-bearing state, which raises a question Phase 3 must answer before the panel ships: when a second window appears above the shade, whose override applies? Measured in Phase 1, not discovered in Phase 3.
Amendment, 2026-08-30: three things this decision left unassigned now have owners, settled in a
grilling pass over docs/PLAN.md.
The safety invariant moves from a View to the composite. MAX_SHADE_ALPHA and MIN_BACKLIGHT
were reasoned about above as bounds on a single value each, which was true while the shade was one
black View. Phase 1 makes it a FrameLayout with two children, and a bound on each child does not
bound the result: black at MAX_SHADE_ALPHA still leaves content faintly visible, and a heavy amber
wash over that remainder is a screen nothing underneath can be read through, reached with neither
child past its own cap. The escape hatches survive either way, since all three sit above
TYPE_APPLICATION_OVERLAY; what is lost is the user’s ability to see their own screen well enough to
reach any of them, which is the whole thing the cap buys. So the warmth layer gets its own constant
beside the other two, and the invariant is stated over the composite. CLAUDE.md’s house rule and
CONTEXT.md’s claim that warmth is independent of dim level are amended with it — warmth is a
separate control, but not an unbounded one.
“The UI has to say out loud” belongs to Phase 1, the phase that causes it. A user whose
brightness slider stops applying will report that Gloam broke their phone, not that a window override
is live, and nothing today connects that symptom to the also lower the screen brightness toggle
that ends it. Whether Gloam should go further and watch SCREEN_BRIGHTNESS with a ContentObserver
stays open, in Phase 3a, answered by the closed testers rather than by one person in a room.
The ramp is unit-tested. It is a pure function with no Android in it, and its failure mode is the
one this ADR exists to prevent: 0.0f is BRIGHTNESS_OVERRIDE_OFF at one end and a fully opaque
screen at the other. Across all 101 inputs the ramp must never emit a backlight below MIN_BACKLIGHT,
never a shade alpha above MAX_SHADE_ALPHA, and never a composite past the warmth bound. The plan’s
old blanket claim that no risk in it was reachable by a unit test was wrong here, and wrong in the
two places that matter most.
Amendment, 2026-08-30 (second). Three of this ADR’s own numbers did not survive being re-read against the phone during the Phase 1 planning pass. The decision stands; its arithmetic does not.
The backlight’s range is not a property of the device. This ADR called the asymmetry “a factor
of ~75”. dumpsys display publishes the panel’s calibration — mBacklight = [6.83661E-4, 0.499951,
0.99975586, 1.0] against mNits = [2.0, 500.0, 1200.0, 2000.0] — so the ratio between the user’s
brightness and the floor is user state, running from 1 (already at minimum) to 1000 (at full) on
this one panel. The “500 nits” above was the user’s setting at the time of the reading, not the
panel’s maximum, and “6.64 nits” was a test float rather than a floor: mScreenBrightnessRangeMinimum
is 6.83661E-4, which the spline maps to exactly the 2.0-nit floor. The override can reach it.
The consequence is in phase-1.md §2 rather than here: the split between the two
stretches cannot be a constant. It is derived from the backlight top, so that a user already at
minimum brightness does not get a slider whose first two-thirds do nothing.
Settings.System.SCREEN_BRIGHTNESS is not a usable reading of the user’s brightness. With the
screen on and the mode manual, this phone reports screen_brightness = 255 while mScreenBrightness
is 6.83661E-4 — the panel at its 2.0-nit floor. The obvious raw / 255 computes a top of 1.0f,
which is 2000 nits, for a user sitting at 2.0. A thousandfold overestimate, in the direction that
brightens the screen. screen_brightness_float reads null here, so there is no free float read
either. The integer’s scale is device-defined and the encoding is gamma; neither has a public API.
This does not reverse the decision. WRITE_SETTINGS would inherit the same scale problem and
keep the stranding failure it was rejected for, so the window override remains the right mechanism.
What it changes is the confidence: the backlight half now depends on an estimate that has to be
verified against the device before it ships, which is why Phase 1 carries an explicit checkpoint that
can veto it and close the phase with the shade-only ramp instead.
Amendment, 2026-08-31 (third). The phase ran, and the checkpoint that could have vetoed the backlight half did not. The decision is unchanged and now shipped; what changes is that the second amendment’s central reading turned out to be an artifact, and that this ADR’s one open question has an answer.
Checkpoint B passed, so the backlight half is in. Computed backlightTop against the panel’s own
mScreenBrightness at screen_brightness 10, 20, 64 and 128 as well as 255, mode manual: the decode
lands 1.2% to 5.0% under the user’s own float and never over it — which is the safe direction,
because reading low can only make Gloam start darker than the user was. With screen_brightness_mode
set to 1 deliberately, adaptive turns out to be an ordinary read: the framework stores its own
choice back into the same integer, and the decode was 3.8% under at 6.3 lux. No null path is
needed on this device, and the toggle-off case shares the branch with it rather than having one of
its own.
The second amendment’s screen_brightness = 255 reading is withdrawn. The panel was in its
inactivity DIM policy at the time, not at the user’s setting — mBrightnessReason=manual [ dim ] -
so the 255 and the 6.83661E-4 were never two views of the same moment. On this phone raw 255 is 500
nits and the two never coincide. So Settings.System.SCREEN_BRIGHTNESS is a usable reading of
the user’s brightness after all, within a few percent and in the conservative direction.
And the lesson that outlives the correction: read a panel with the screen held awake
(settings put system screen_off_timeout 600000, and put it back). Four of Phase 1’s readings were
first taken inside the inactivity timeout, and every one of them looked exactly like the setting did
nothing.
Nothing downstream of that withdrawal moves. The ramp still derives the split between its two
stretches from backlightTop rather than from a constant, because that argument was never about the
integer being unreadable — it was about the range being user state, running from 1 to 1000 on one
panel, which the withdrawal does not touch. MIN_BACKLIGHT was 0.01f, set by measurement rather
than by argument: 6.64 nits on the development panel, read against the criterion that the
notification shade can be pulled down and Stop tapped in a dark room and a lit one. (Since the
fifth amendment it is the panel’s floor, 6.83661E-4f, 2.0 nits.)
The open question in Consequences — “when a second window appears above the shade, whose override
applies?” — is measured. From both sides, and it is the same rule: the topmost window that asks
for a brightness gets it, and a window that is hidden asks for nothing. The system’s own surfaces
(notification shade, quick settings, volume dialog) read our 0.01 back unchanged. An ordinary app
window below ours is not consulted at all — MIUI’s video player in swipe-to-dim asked for 1.0 and
then for 0.0078, either side of ours, and neither reached the panel; it is the topmost value that
applies, not the lowest. The two exceptions are both the system taking the window away rather than
out-ranking it: the keyguard releases the override outright and returns it on unlock, and a
runtime permission dialog force-hides every non-system overlay, which hands the user’s own
brightness back for exactly as long as the dialog is up.
What that leaves for Phase 3b is narrower than it was, but it is not answered. What was measured is a requester below ours losing. A panel window of Gloam’s own above the shade that sets no brightness of its own should leave the shade the topmost requester by the same rule — but “should, by the rule” is not a reading, and 3b owns taking it.
Amendment, 2026-09-03 (fourth). The open question is closed by a reading, and the answer is the same rule. Consequences left one question for Phase 3 — “when a second window appears above the shade, whose override applies?” — and the third amendment narrowed it to Gloam’s own windows and refused to answer it without a device. Phase 3’s checkpoint A took that reading before a line of the panel was written, because a no-go would have cut the phase in half.
Go, and it is the outcome that needs nothing built (phase-3.md R1, 2026-09-02). With the shade
live at dim 100 and a bare 200 dp square added above it as a second TYPE_APPLICATION_OVERLAY:
dumpsys window windowsprints the stack top-first and the two come back adjacent and the right way round — the square atWindow #8, the shade at#9. The lateraddViewis on top, which is what insertion order predicted and what nothing documents.- With the square above and asking for no brightness of its own,
mWindowManagerBrightnessOverride=0.01and the tag still Gloam’s. Removing the square changed neither; stopping the shade released it toNaN.
So the topmost window that asks owns the override, and one that declines is not consulted —
between two windows of ours exactly as it held against everyone else’s. The panel therefore keeps
BRIGHTNESS_OVERRIDE_NONE and the shade keeps the override, which is what makes the panel legible at
the backlight the shade left rather than under the shade, where every Activity of ours sits (R3, R6).
That was 6.64 against 1.59 nits when measured; since the fifth amendment it is 2.0 against ≈0.48.
(Both shaded figures are stored values. In light they are ≈0.31 and ≈0.09: the seventh amendment.)
The field-copy fallback the phase had written down — the panel carrying the shade’s own
screenBrightness on every update — is not built, and stays in phase-3.md §6 as the thing to
reach for if a ROM ever disagrees.
Read this amendment with ADR-0011. This one says the second window may exist without disturbing the ramp; that one says what stops it trapping the user once it does.
One thing R1 found that it was not looking for, and R13 named: MAX_SHADE_ALPHA is not the alpha
that lands. Both of Gloam’s overlay windows printed alpha=0.8 in mAttrs, a LayoutParams field
this app never sets. It is Android 12’s untrusted-touch cap — an app overlay that lets touches
pass through may not obscure what is under it by more than
maximum_obscuring_opacity_for_touch, unset on both the phone and the emulator and therefore at the
framework’s default 0.8, which the window manager writes into the window’s alpha. Proven by moving
it: at 0.5 the shade comes back alpha=0.5 (phase-3.md R13). The panel carries no alpha at
all, because it catches touches rather than passing them.
So this ADR’s cap is not the binding one, and the error runs in the safe direction. R3 and R6
measured 0.2393 and 0.2411 of the content still reaching the eye where the two invariants compute
0.05 — that is 1 − 0.8 × MAX_SHADE_ALPHA = 0.24, and it is lighter than computed, which is the
right direction for the bound that keeps the way out visible and the wrong one for the thing the app
is for. Nothing here changes: the invariants still hold, the tests still keep them, and a cap that
binds tighter than ours cannot break a bound stated as a floor on transmitted light. What it
changes is Phase 2b’s ceiling — ultra dark is defined as going past MAX_SHADE_ALPHA, and past a
point more alpha buys nothing at all while the platform pins the window at 0.8. That is in DOD.md
against 2b, priced before the phase opens rather than discovered inside it.
Amendment, 2026-09-13 (fifth). MIN_BACKLIGHT moves from 0.01f to the panel’s floor,
6.83661E-4f: from 6.64 nits to 2.0 on the development panel, the dimmest backlight Android
gives an app. The decision stands. The one number the third amendment set by measurement is re-set
by choice, and this amendment says what the choice costs.
Why. The Phase 2b ceiling was priced in DOD.md. At dim 100 the old ramp let 1.59 nits
reach the eye, and every lever past it except one was ruled out: raising the shade cap buys about
17% and breaks this ADR’s invariants, FLAG_DIM_BEHIND blocks every touch, and a second shade window
blocks touches because one uid’s opacities add up. The backlight was the lever left, and at the floor
dim 100 is ≈0.48 nits (≈0.09 in light, by the seventh amendment), about 3.3× darker. The goal is headroom: a level someone actually reads
at that sits well below 100, rather than a darker number at the top.
What it costs is R2’s criterion, knowingly. 0.01f was the value at which the notification
shade could be pulled down and Stop tapped in a dark room and a lit one, with margin. R5 is why that
one number carries the whole argument: the system’s own surfaces read our override back unchanged. At
the floor they sit at 2.0 nits rather than 6.64, and so does the panel. Three things do not move. The
keyguard still releases the override outright, so the power button still brings back the user’s own
brightness. A ROM kill still releases it. And the shade’s cap and the two invariants are unchanged.
R2 is owed again, by eye, at the new value, and the debug build keeps a switch back up to 0.01f
until it is taken.
Other panels. The floor is not readable through a public API, so the constant is this panel’s.
An override below a panel’s floor is clamped up to it (R1, on this device). So a panel with a higher
floor reaches its own floor after a short flat stretch at the bottom of the backlight half, and a
panel with a lower floor stops above it. Neither is a safety question. Never 0f still holds,
and ShadeRampTest still holds it.
The affine distortion now bites. ShadeRamp.kt declined to correct for the panel’s affine
response because 0.01f kept the ramp out of the float’s bottom decade. The floor is inside that
decade: between the two values the float falls 14.6× and the light only 3.3×, so the bottom of the
backlight half darkens the screen more slowly per point. Recorded there, and left alone until someone
notices.
Amendment, 2026-09-13 (sixth). The warmth child’s colour becomes a setting on a fixed path, from
SHADE_AMBER to SHADE_RED, where it used to be the one constant. The decision and both invariants
stand. The second invariant is now a sweep over the path rather than an assertion on one colour.
Why. The tester’s verdict on the Phase 2b build was that the warmth colours were poor. Other dimmers call this control a reading mode and give it a colour of its own: Twilight has colour temperature, intensity and dim, and HyperOS’s system Reading mode has a warm tint with its own strength. So Gloam gets a warmth colour beside warmth. Its name stays warmth, because HyperOS already puts a “Reading mode” in the same quick settings.
The path is shaped by the veil bound. Both ends sit at the same relative luminance, 0.0727, and
they are blended in linear light, where luminance is linear. So every point on the bar adds the same
veil, and the invariant cannot depend on where the handle sits. A free colour picker was rejected on
this bound: any bright colour would have to be held to a near-zero alpha to pass, so most of a picker
would do nothing. The setting is one integer, so it is a setting and not a table (ADR-0007).
What the colour does, measured on the composite rather than claimed. Black text on a white page under full warmth and no dim:
| Warmth colour | Tint | Contrast | Green light left | Blue light left |
|---|---|---|---|---|
| 0 | #7A3B00 |
6.0 : 1 | 34% | 21% |
| 50 (default) | #8D2900 |
5.8 : 1 | 30% | 21% |
| 100 | #9E0000 |
5.3 : 1 | 21% | 21% |
The blue is the same at every point, because neither end carries any: warmth sets the blue, and
the colour trades green light and a natural-looking screen against a little contrast. The default
is the middle, about the hue of a 1000 K light, where Twilight’s documentation puts its ideal colour
temperature. The red end is there for a fully dark room, since red is the light night vision is least
sensitive to. None of this is a sleep claim. A 2021 study of iOS Night Shift found no difference in
sleep, and Gloam’s listing already avoids health wording (DOD.md).
The compact controls and the panel carry warmth without its colour. The colour is set once, and
strength is what somebody changes while reading. Their warmth column is painted in the chosen tint, so
those surfaces still show it. PLAN.md’s colour filters beyond warmth stays out of scope: this is
warmth’s own colour, not a second filter.
Amendment, 2026-09-13 (seventh). The shade’s alpha is derived from the light it should leave, rather than set to the light it should remove. The decision, the cap and both invariants stand. What changes is the one line of the ramp that turned a share of light into an alpha. It had been wrong since Phase 1, and nothing on screen said so.
Why. The night-reading research (night-reading-research.md §1)
noticed that the fourth amendment’s readings are stored values, not light. Two platform facts sit
between an alpha and the eye: the 0.8 window-alpha clamp above, and a compositor that blends
gamma-encoded values, which the panel then turns into light along the sRGB curve. So the black child
at a leaves a white page linearise(1 − 0.8a) of its light, roughly (1 − 0.8a)^2.2, rather than
1 − a.
Read off the phone, with no light meter. There is none to hand, and the phone’s own light sensors cannot see its screen: the front one sits under the panel, and the other is on the back. Three readings stand in for one:
- R3 and R6 read the shade at dim 100 as 0.2393 and 0.2411 of the page’s stored value. Blending in light would have read 0.53.
dumpsys SurfaceFlingerlists the shade asCLIENTcomposition in 10 reads out of 12. That is the same GPU rendererscreencapuses, so those values are what the panel receives. In the other two it wasDEVICE, composited in hardware, and a hardware composer is required to produce the same pixels as the GPU path it stands in for.- The display runs in
ColorMode::SRGB, whose curve is the onelineariseundoes.
The last step is the display standard rather than a photon count. Any exponent from 2.0 to 2.4 leaves every conclusion below standing.
What it had cost. With the backlight toggle off, a white page kept:
| Dim level | Meant | Before | Now |
|---|---|---|---|
| 20 | 54% | 37% | 54% |
| 60 | 16% | 9% | 16% |
| 100 | 4.7% | 4.7% | 4.7% |
Most of the darkening happened in the first half of the slider, and the last forty points only halved
the light. ShadeRamp.kt said the shade half was “exactly geometric because alpha genuinely
multiplies” and that the perceived rate could not kink. Neither was true.
What moves, and what does not.
- Dim 100 is no darker than it was, because
MAX_SHADE_ALPHAdoes not move. The figure this ADR quotes for it does: ≈0.09 nits at the floor, not ≈0.48. The shade had always been darker than the fifth amendment said, and so had anything of Gloam’s own under it. The fourth amendment’s 1.59 was ≈0.31 in light. - Every level between the handover and 100 is now lighter than before, because the alpha climbs later. Warmth’s ease starts at dim ≈86 with the toggle off, and ≈96 with it on at full brightness, rather than ≈71 and ≈91.
- The invariants are stated over alphas, where the two layers multiply, so this change cannot
move them.
ShadeRampTestholds them unchanged. Its constant-rate test now measures light throughshadeTransmission, and a new test pins that model to R3’s reading. - The clamp becomes a constant,
WINDOW_ALPHA_CLAMP, read rather than chosen. It shapes the ramp and does not bound it: on a device where somebody has moved the global, the steps are uneven and the child still stops atMAX_SHADE_ALPHA.
Amendment, 2026-09-16 (eighth). The warmth colour’s default was tested and holds at 50, on a
reading that says the colour does not change reading speed at all. Nothing in the decision, the
ramp or either invariant moves. What changes is the standing of one number: DEFAULT_WARMTH_COLOR is
no longer a recommendation derived from the sixth amendment’s table, it is a recommendation the table
supports and a measurement declines to contradict.
Why it was tested. The night-reading review
(night-reading-research.md §3) read the mesopic literature as saying
deep red looks 17–28% darker than amber at equal luminance and that red text is the first to slow
reading, and proposed moving the default from 50 towards 25. The sixth amendment’s table is
photometric — contrast and spectrum on the composite — and photometry cannot answer whether a page is
slower to read. So the move was made conditional on a reading, and a timed test went into the debug
build to take one.
What was run. Three sessions of ui/settings/ReadingTest.kt on the Xiaomi, 2026-09-13, -15 and
-16: a white page with black 12sp text under the real shade at dim 90 and warmth 100, one seven-word
sentence per trial judged true or false, colours 0/25/50/100 in sixteen mirrored blocks, 96 trials a
session, 288 in all. Response time is the geometric mean of correct trials at rt_ms ≥ 300, ratio
against colour 50, 95% bootstrap interval resampled within each night.
| Warmth colour | Accuracy | Geomean RT | Ratio vs 50 (95% CI) |
|---|---|---|---|
| 0 | 97% | 2932 ms | 0.946 (0.839–1.066) |
| 25 | 93% | 3112 ms | 1.004 (0.887–1.132) |
| 50 (default) | 97% | 3100 ms | 1.000 |
| 100 | 99% | 3220 ms | 1.039 (0.925–1.163) |
The answer is a null, and it is worth more than the change it withheld. Every interval crosses 1.0, accuracy is at ceiling everywhere, and the rule set before the first session — move to 25 only if 25 is clearly faster — landed on 1.004. The individual nights disagree in opposite directions: night 1 had deep red 22% slower, night 2 had amber 23% faster, night 3 had amber slowest and deep red fastest. Two “significant” nights pointing opposite ways and a third pointing neither way is what a colour with no real effect looks like at 96 trials split four ways. 288 trials resolve a colour-vs-50 contrast of about 11%, so what this rules out is a reading cost that large; the literature’s 17–28% darkness gap is not appearing as one over a white page.
What it means for the bar. The sixth amendment’s trade — green light against a little contrast — is unchanged and is still the whole argument for where the default sits. What the test removes is a reason to move it: there is no reading penalty at the red end to steer users away from, and no reading gain at the amber end to steer them towards. The colour bar is a matter of taste, and the default is the middle because the photometry likes the middle. A reading-speed claim for any point on it would now be contradicted by our own data, so the UI makes none — the bar is labelled Colour and says nothing else.
Rows and per-night summaries live outside the repo, in ~/gloam-data/reading-test/; the public record
is this amendment and §3 of the research note.