Phase 1 — The mechanism is complete
The detail for one phase, written when it opened. Sequence lives in PLAN.md; the live
worklist lives in DOD.md; the decision this phase implements is
ADR-0010 and both of its
amendments — the first assigns owners, the second records what the phone said when this phase was
planned and the ADR’s own numbers were re-read against it.
What closing this phase means. Gloam subtracts light three ways instead of one — the backlight walked to its floor, the shade drawn over what is left, and the shade tinted amber — driven by the single dim level the product is about. The app reaches the place it exists to reach, and it says out loud the one thing it breaks on the way.
What is in, and what is deliberately not
In: the notification-permission entry gate; the backlight override; the ramp as a pure, tested function; the warmth layer and the composite bound; the copy that explains the inert brightness slider; the readings this phase’s constants depend on, and two taken for later phases’ benefit.
Not in, and the phase that owns each: ultra dark and the Quick Settings tile (2b), auto-off and
first-run coherence and reboot restore (2), the panel (3b), a ContentObserver on SCREEN_BRIGHTNESS
(3a), the schedule (4). The one that will feel tempting is the panel: the moment the shade owns a
window brightness, putting a slider in a second window is nearly free. It is not — see 3b’s
lifecycle cost, and the go/no-go that falls out of checkpoint B’s readings.
One shape to keep, without building it. Phase 3a renders the same controls in a second host, so
the dim slider, the warmth slider and the backlight toggle should sit in a composable that takes its
state and callbacks as parameters and knows nothing about Scaffold, the top bar or the service.
Do not extract DimControls() yet — extracting a thing with one caller is guesswork — but do not
write it so that extracting it later means unpicking it.
Checkpoints
The phase is one phase and five merges. Each checkpoint leaves the app working, ships its own
copy complete in both locales — scripts/translation-gate.py is a merge gate, so that is enforced
rather than remembered — and is a point at which the phase could stop without stranding anything.
| Checkpoint | Merges | Depends on | |
|---|---|---|---|
| A | The entry gate | feat: |
nothing — closes a pre-door DOD.md item on its own |
| B | The sweep, then the readings | chore:, then no commit at all |
A landed, phone attached |
| C | The backlight joins the ramp | chore: ramp + test, then feat: |
B’s verdict |
| D | Warmth and the composite | feat: |
C, or B’s veto |
| E | The documents | docs: |
everything above |
B is a gate, not a task. It can veto C. If the backlightTop verification fails — see §2 — the
backlight half does not ship, C is skipped, and D lands against the shade-only ramp, which is the
same function taking the same branch it already takes when the toggle is off. Nothing is stranded by
that outcome and no stored key changes; the phase closes narrower and says so.
1. Entry gate — POST_NOTIFICATIONS, before anything else
work/NotificationPermission.kt has existed since Phase 0 and nothing calls it. This phase is the
first one that makes that a safety problem rather than an untidiness: it produces the darkest state
the app will ever reach short of ultra dark, and the ongoing notification’s Stop action is the
documented way out of it. On Android 13+ that notification does not exist until the permission is
granted, so the property CLAUDE.md claims — the foreground notification is the escape hatch — is
today only half true.
Where the ask goes. In DimScreen, fired from the start button, before the first
startShade() and never after. Not at first launch, per rule 4: the ask waits for the moment the
feature needing it is switched on.
Why never after — this is a platform constraint, not a preference. The system hides or refuses
touches on non-system overlay windows while a permission dialog is up, precisely so that an app
cannot draw something over the dialog and trick the user into tapping Allow. Our shade is
FLAG_NOT_TOUCHABLE, which may or may not exempt it — nobody should assume either way. So the rule
in code is: the ask is only ever raised with no shade on screen. If a later phase needs to re-ask
while the shade is up, it stops the shade first or routes to app settings instead. Read the actual
behaviour off the phone anyway (§7 R7) — the rule stands regardless, but the answer decides how much
Phase 2 has to work around.
“The first” is answered without storing anything
Android permits two denials before the dialog stops appearing, so who spends them is a design
question and not a detail. NotificationPermission.kt’s own comment already leans on the fact that
shouldShowRequestPermissionRationale is exact after a denial; it is ambiguous only between
never asked and spent, and those two want identical behaviour — fire the launcher, which either
shows the dialog or returns PermanentlyDenied immediately. So:
| State | Start button | The warning’s button |
|---|---|---|
| Granted | starts, no ask | not shown |
| Never asked | asks (spends the first) | not shown yet |
| Rationale pending — one denial spent | starts, no ask | asks (spends the second) |
| Spent | starts, launcher returns instantly | opens settings |
The second denial is only ever spent by a deliberate tap on a control that says Allow
notifications. No DataStore key is needed, which matters more than it reads: rule 2 freezes
every written key at the door, and DOD.md is already carrying onboardingDone as the open example
of a key added to answer a question that had a stateless answer.
The ask is asynchronous, so startShade() moves into the outcome callback — and setRunning(true)
moves with it. Both, together, in all three outcomes. If the intent were written on the button
press and the user backgrounded the app mid-dialog, DataStore would say running with no service
alive, and Phase 2’s reboot restore reads exactly that flag. Keeping them together also makes the
“no shade on screen during the dialog” rule true by construction rather than by convention.
What happens on a refusal
The shade still starts. Refusing to dim someone’s screen because they refused a notification is the app deciding it knows better; the honest move is to run and to say what they have given up. The gate that genuinely blocks a feature on a live escape hatch is Phase 2b’s.
The warning is a live read, not a remembered outcome
DimScreen already re-reads canDrawShade() on every ON_RESUME, with a comment saying why: it is
a special permission granted on a settings screen, and the user can revoke it there while the app is
backgrounded, so a cached true becomes a service that cannot add its window. POST_NOTIFICATIONS
has the identical property — and the PermanentlyDenied path sends the user to that settings screen
deliberately. An outcome-driven warning is therefore stale exactly when it matters: they grant it,
come back, and the banner still says they have no Stop button.
So the warning is derived, on resume, from whether the notification can actually appear:
areNotificationsEnabled() && channel(Shade).importance != IMPORTANCE_NONE
Both halves, because the warning’s claim is a safety claim. §1 of the old draft named the channel
case and handed it to Phase 2b — but 2b’s gate blocks ultra dark, and this informs. A false
negative here is a user at maximum darkness with no notification and nothing on screen saying so.
Channels.kt and openChannelNotificationSettings() both already exist and are both uncalled, so
the read costs a few lines against code the repo is already carrying.
Shown only while running is true, so it never greets a first-launch user who has started
nothing. The outcome enum stops deciding whether the warning appears and only picks which button
it carries:
| Live state | Button |
|---|---|
| Permission off, rationale available | Allow notifications (re-ask) |
| Permission off, dialog spent | Open notification settings |
Permission on, channel importance NONE |
Open notification settings → openChannelNotificationSettings() |
It self-clears the moment the user fixes it, and it appears if they revoke it mid-session — a case an outcome-driven banner would miss entirely.
Closing this checkpoint ticks Ask for POST_NOTIFICATIONS in DOD.md and removes
work/NotificationPermission.kt from that file’s list of scaffolding describing features the app
does not have.
2. The ramp
One dim level, one ramp, in a fixed order (ADR-0010). A pure function, in shade/ShadeRamp.kt, with
no Android imports at all — that is what makes it testable on the JVM and what keeps rule 3’s
promise that the safety values are proven by test rather than by looking at a screen.
/** What the user asked for. Read from DataStore, combined into one value. */
data class DimSettings(
val dimLevel: Int, // 0–100, the one value the product is about
val warmth: Int, // 0–100
val lowerBacklight: Boolean,
)
/** What the shade's window and its two children carry. */
data class ShadeValues(
val backlight: Float?, // null = release the override; the user's own brightness applies
val shadeAlpha: Float,
val warmthAlpha: Float,
)
fun shadeValuesFor(settings: DimSettings, backlightTop: Float?): ShadeValues
Two triples, one the input to the function producing the other, both three fields, both in shade/.
CONTEXT.md exists because this app has exactly that hazard, and it already carries the distinction
that separates them: dim level is what the user asks for, shade is the mechanism that
delivers it. backlight belongs in ShadeValues rather than looking out of place there — the
override is an attribute of the shade’s own window.
backlightTop is a parameter rather than something the function reads, which is the whole trick:
the messy, device-dependent question of where the backlight starts stays outside the pure
function, and the function stays exhaustively testable.
One geometric ramp in total light, spending the backlight first
There is no BACKLIGHT_STRETCH constant. An earlier draft split the slider at a fixed
breakpoint derived from one measured ratio. The phone says that ratio is not a property of the
device at all — it is user state, and on this one panel it moves by three orders of magnitude:
| The user’s brightness | nits | ratio to the floor | where the backlight runs out |
|---|---|---|---|
raw 255 - their maximum |
500 | 250 | dim 65 |
raw 128 |
251 | 125 | dim 62 |
raw 39 - the device default |
76 | 38 | dim 55 |
raw 20 |
39 | 19 | dim 50 |
raw 10 - their minimum |
19 | 9.5 | dim 43 |
A fixed breakpoint at 65 would give the person already at their system minimum a slider whose first 65 points travel from 19 nits to 2.0 - and whose last 35 do everything else. That is precisely Gloam’s user: someone who reads in the dark and finds the lowest system setting still too bright, and the one whose experience a fixed breakpoint degrades most.
The measured spread is 43 to 65 rather than the 0 to 70 an earlier draft assumed, because this
phone’s slider stops at raw 10 and cannot reach the panel floor at all - so the “65 points that do
nothing” case needs a device whose configured minimum is far lower than this one’s, which is a
per-device resource and so is a device somebody owns. The conclusion is unchanged and the reason is
now narrower: a fixed breakpoint is wrong by 22 points across one phone’s own settings range.
So the ramp is stated once, over the quantity that matters, and the breakpoint falls out:
top = backlightTop, when the override is in play
ratio = top / MIN_BACKLIGHT // 1.0 when there is no backlight to spend
span = 1 / (1 - MAX_SHADE_ALPHA) // 20 at 0.95 — what the shade alone can take
t = dimLevel / 100
light = (ratio * span) ^ -t // fraction of `top`'s light still reaching the eye
backlight = max(top * light, MIN_BACKLIGHT)
shadeAlpha = 1 - min(1f, light * ratio)
Read it as: the total light falls at a constant ratio per point from 0 to 100, and the backlight is
spent before the shade is touched. Below the breakpoint light × ratio ≥ 1, so shadeAlpha is 0
and there is no overlay over the user’s content at all — the concrete thing ADR-0010 bought by
sequencing rather than blending. Above it the backlight is pinned at MIN_BACKLIGHT and the shade
supplies the remainder.
Four things this buys that the two-stretch version had to assert:
- No dead zone, for any user. The breakpoint is
log(ratio) / (log(ratio) + log(span)), which is 0 when there is no backlight range to spend. - The toggle-off branch disappears. Toggle off, or
backlightTop == null, isratio = 1, which islight = span^-t, which isshadeAlpha = 1 - (1 - MAX_SHADE_ALPHA)^t— the shade-only ramp, from the same expression with noif. - Monotonicity and continuity are true by construction, not by assertion. §8 still tests them,
because a fencepost in
twould break both. - The perceived rate never kinks, because there is no seam to kink at.
Dim level 0 is the one special case: the override is released (backlight = null), not set to
top. CONTEXT.md already says a dim level of zero is still running; this is what that means
physically — the shade is transparent, the backlight is the user’s own, and their slider works.
The same dim level is not the same darkness under the two toggle settings, and it is not the same darkness for two users at different brightness. That is what “100 means as dark as currently allowed” already committed to, said out loud rather than hidden.
Why geometric, and the claim the phone withdrew
Perception of brightness is roughly logarithmic, so equal slider travel should buy an equal ratio of light, not an equal subtraction of it. Linear alpha is visibly wrong here: half-way through the shade stretch a linear ramp still transmits 52% of the content, so most of the travel is spent in the barely-dimmed region and all the useful darkness is crammed into the last few points. Geometric transmits 22% at the same place.
An earlier draft claimed this was right whatever the panel’s response curve turned out to be, on
the grounds that nits ∝ float^γ for any γ. The phone withdrew that. dumpsys display publishes the
device’s own calibration:
mBacklight = [6.83661E-4, 0.499951, 0.99975586, 1.0]
mNits = [2.0, 500.0, 1200.0, 2000.0]
Below the knee at float 0.5 that is affine, not a power law — nits ≈ 997.5 × float + 1.3 — and
there is a second, steeper segment above it. The 1.3 offset is a large share of the output near the
floor, which is exactly where this product lives, so geometric-in-float and geometric-in-nits diverge
most where it matters. The ramp works in float because float is all an app can set; R1 measures how
far apart they are, and the answer is a correction, not a reassurance.
MIN_BACKLIGHT is a floor on the escape hatch, not on the driver
An earlier draft defined it as the smallest float that still produces a lit panel. That is a display-driver question, and the property it is load-bearing for is an ambient-light one.
The reasoning that changed it: the escape hatches sit above TYPE_APPLICATION_OVERLAY, so
MAX_SHADE_ALPHA never dimmed them. It protects the user’s ability to see their own content well
enough to navigate. The backlight override dims everything, the escape hatch included — and it
does so on a value the user can no longer change, because their slider is inert while it is live.
Phase 0 could not strand anyone this way: its darkest state was the user’s own backlight × 0.05, and
their slider still worked.
So MIN_BACKLIGHT is the smallest float at which the notification shade can be pulled down and
Stop tapped, in a dark room and under normal room light, on both the phone and the API-33 AVD, with
margin. The driver’s floor is a hard lower bound on that search, not the answer to it. R2 is the
reading; R5 decides what it is measured against, because if the system’s own windows carry their own
brightness above ours, the escape hatch is lifted and the search is much easier.
It is a floor on our own output. The display driver clamps below it anyway, and that clamp is the
thing we must never be seen to be asking to go past — screenBrightness = 0.0f is documented as
BRIGHTNESS_OVERRIDE_OFF, the backlight off, over a live touchscreen.
The constants
Four now, beside each other, all in shade/, all with the comment that says why each is not a
preference.
| Constant | Value | Why it is a safety constant |
|---|---|---|
MAX_SHADE_ALPHA |
0.95f (settled, Phase 0) |
1.0 is a black rectangle with every way out behind it |
MIN_BACKLIGHT |
from R2, never 0.0f |
above |
MAX_WARMTH_ALPHA |
0.5f |
§3 |
WARMTH_EASE_FROM |
0.88f |
§3 |
And one colour, SHADE_AMBER, bounded by an assertion rather than by taste — §3.
Where backlightTop comes from
The one genuinely device-shaped question in the phase, the reason the ramp does not read it, and — after the section above — the thing that sets the shape of the whole slider rather than merely its starting point. This is what checkpoint B gates.
Settings.System.SCREEN_BRIGHTNESS is readable with no permission — reading is free, only writing
needs WRITE_SETTINGS, which ADR-0010 rejected. What it gives back is awkward in three ways, none of
which has a public API to fix: the integer’s maximum is device-defined rather than a documented 255,
the mapping from that integer to the window override’s 0f … 1f is gamma-encoded by the framework,
and when adaptive brightness is on the stored value may not be what is on screen at all.
How badly, measured on this phone, with the screen on and the mode manual:
screen_brightness (int) = 255 <- the top of a 10 ... 255 scale
mScreenBrightness (float) = 0.499951 <- 500 nits, this panel's non-HBM maximum
screen_brightness_float = null <- no free float read; that path does not exist here
⚠ An earlier draft of this section recorded mScreenBrightness = 6.83661E-4 beside that same
integer and built its whole argument on the thousandfold contradiction. That reading was an
artifact. A screen approaching its inactivity timeout enters the framework’s DIM policy and is
pinned to the panel’s floor whatever the setting says - nothing in the number gives it away, and the
only place it shows is mBrightnessReason=manual [ dim ]. The debug sweep holds
FLAG_KEEP_SCREEN_ON while it runs for exactly this reason. It generalises past brightness: a
device reading taken while the screen is on its way out is a reading of the timeout.
What the phone does, once it is actually awake (R1 and R3, section 7):
| The setting’s integer scale | [10, 255] - config_screenBrightnessSettingMinimum/Maximum |
| Integer to applied float | linear from 1: (raw - 1) / 254 * max. Not gamma-encoded |
| The user’s own reach | 19 nits at their minimum, 500 at their maximum |
| The window override’s reach | the same - override 1.0 and setting 255 are the same 500 nits |
| Below the user’s floor | the panel goes to 2.0 nits, which their slider cannot ask for |
The last two rows are the whole answer. The framework normalises the setting and the override onto
one range before the panel sees either, so backlightTop is a ratio, not a brightness - what
fraction of their own maximum the user is at. Nothing has to be known about nits, gamma, or where
this panel’s high-brightness knee sits, which is why the decode ended up three lines long.
And the product’s thesis, measured rather than asserted: the system’s slider stops at 19 nits and the panel goes to 2.0. There is a factor of 9.5 in the dark that Android will not hand the user and Gloam can, before a shade is drawn over anything at all.
So: read once, decode, verify, hold, and degrade safely.
- Read the scale from
config_screenBrightnessSettingMaximumviaResources.getSystem().getIdentifier(...). That is a resource lookup by name, not a non-SDK Java member, so the hidden-API blocklist does not reach it - but the resource may be absent, in which casegetIdentifierreturns 0 and we know we failed. - Map linearly, and from 1 rather than from that configured minimum. The framework’s own
conversion starts at
BRIGHTNESS_OFF + 1, and the measurement agrees with it to a tenth of a percent across the range. Decoding from 10 instead is 48% low at the bottom of the slider - the safe direction, but it visibly halves the screen the instant the override engages. - Then trim 5% off the result, and clamp into
0f ... 1f. Untrimmed the decode is within 0.1% in the middle of the range and 3.7% high at the bottom of it, which is the one direction that matters; 5% covers that with margin, and is under both the ramp’s own first step and the threshold of what an eye can see. - On any doubt,
backlightTopisnulland the backlight half does nothing for that session. Never fall back to1.0f: a fallback that brightens the screen is the mirror image of the failure the constants exist to prevent, and “Gloam blasted my screen” is the same support mail as “Gloam blacked out my phone”. - Capture at the moment the override goes from released to applied — the level crosses 0, or the toggle is switched on — and hold until it is released again. Holding is what makes it correct: the stored setting still moves while the override is live, because the user’s slider writes to it blind, so a re-read mid-override would pick up a value they set without being able to see it. It is also what makes the value re-readable in practice — a user who wants to change their brightness drags Gloam to 0, which releases the override and makes their own slider live again.
Checkpoint B’s verdict: passed on 2026-08-30, and C is cleared to spend the backlight. The
debug build’s own computed backlightTop was held on the window and compared against
dumpsys display’s mScreenBrightness at five user settings across the range, mode manual (R3):
screen_brightness |
the user’s own float | holding the computed top | |
|---|---|---|---|
| 10 - their minimum | 0.0177148 | 0.017502489 | 1.2% under |
| 20 | 0.03739791 | 0.03614602 | 3.4% under |
| 64 | 0.1240036 | 0.118308894 | 4.6% under |
| 128 | 0.2499755 | 0.23781857 | 4.9% under |
| 255 - their maximum | 0.499951 | 0.47497055 | 5.0% under |
Never above, at any setting. The test was “if the computed value ever exceeds the actual the backlight half does not ship”, and it does not exceed it - by construction rather than by luck, which is what the 5% trim is for. The first attempt trimmed 3% and came out 0.5% over at the bottom of the slider; that is the measurement that set the constant, and it is why the constant is 5% rather than a rounder-sounding number.
Adaptive brightness is resolved, and it is the good branch (R4). Set screen_brightness_mode to
1 and the framework writes its own automatic choice back into the same integer: at 6.3 lux it
picked 27.2 nits and stored screen_brightness = 14, which the app read and decoded to 3.8% under
what was on the panel. So adaptive needs no special case at all - the stored integer is a live
reading of what the framework last chose rather than a stale manual one, and the override replaces
the automatic strategy cleanly (reason=override(...)) and hands it back on release. The blanket
adaptive to null rule is not needed, which matters because adaptive is on by default on most
Android devices and that rule would have shipped this phase’s headline mechanism to a minority.
One thing it does confirm: the capture once and hold rule above is load-bearing under adaptive for a second reason. The framework keeps re-choosing as the light changes, so a re-read mid-override would pick up a value chosen for a room, not for the user.
3. Warmth, and the invariant moving to the composite
The shade stops being one View and becomes a FrameLayout with two children — still one
window, so every safety flag, the type, the cutout mode and stopWithTask are untouched. That is
the reason to do it now rather than retrofit it: adding a child costs nothing, adding a window costs
Phase 3b.
What an overlay can and cannot do — platform, not choice
A window composites over what is behind it with source-over and nothing else. An overlay can only
add light; it cannot subtract a colour channel. There is no blend mode an app can ask for against
content it does not own. So warmth works by adding amber to a screen that is already dark, shifting
the average colour — it does not filter blue out of the light underneath, and CONTEXT.md’s
“cutting blue” is wrong in the strict sense and moves with this phase (§6). CONTEXT.md was already
right to avoid the word filter entirely; this is why.
The algebra, which is most of the safety argument
Two stacked translucent layers over content, black at alpha a, amber at alpha w:
content signal reaching the eye = (1 - a) * (1 - w)
That product is the reason the invariant had to move. Black at 0.95 and amber at 0.5 puts
neither child past its own cap and leaves 2.5% of the content — half of what the black layer
alone was ever allowed to leave. The escape hatches survive regardless, 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.
The first invariant, stated over the composite:
(1 - shadeAlpha) * (1 - warmthAlpha) ≥ 1 - MAX_SHADE_ALPHA
Read it as: the composite may never take more signal than the black layer alone was allowed to take. It needs no new number justified from scratch — it re-uses the constant already argued for, which is why this shape was chosen over inventing another one.
The second invariant, which the first cannot see
Source-over is w × amber + (1 - w) × content. The amber does not only attenuate the content, it
lays veiling light on top of it, and how much depends entirely on the amber’s own luminance —
the very thing the section above says is the whole mechanism. The signal bound cannot see that term
at all. A bright amber passes it and produces an unreadable screen:
| amber | relative luminance | w × L at w = 0.5 |
signal : veil at a = 0.88 |
|---|---|---|---|
#FFB000 |
0.523 | 0.262 | 0.23 : 1 — four times more veil than content |
#7A3B00 |
0.073 | 0.036 | 1.7 : 1 |
#3D1A00 |
0.017 | 0.009 | 6.9 : 1 |
Every assertion in §8 is green on the first row. So the amber’s luminance is not a taste note; it is the other half of the argument, and it gets an invariant in the same idiom — reusing the constant already argued for, inventing nothing:
MAX_WARMTH_ALPHA * relativeLuminance(SHADE_AMBER) ≤ 1 - MAX_SHADE_ALPHA
The amber may never add more light than the black layer was allowed to leave. At
MAX_WARMTH_ALPHA = 0.5f that caps the amber’s relative luminance at 0.10. It is a compile-time
assertion in ShadeRampTest — no device, no meter, no photometry — and it turns “pick a dark
amber” from advice into a constraint the build enforces.
It also proves §3’s exception to the house rule rather than asserting it. The brand seed
B0763C (dusk amber, gen_scheme.py) has relative luminance 0.224, so 0.5 × 0.224 = 0.112
and it fails by more than twice. The shade’s amber genuinely cannot come from MaterialTheme, and
now there is a number saying why rather than three sentences of reasoning.
Warmth yields, and it yields gently
At shadeAlpha = MAX_SHADE_ALPHA the first invariant forces warmthAlpha = 0. Something has to
give at the very top, and it cannot be the dim level: that is the one value the product is about. So
the applied warmth is the user’s warmth scaled by the headroom the dim level leaves:
headroom(a) = clamp01( (MAX_SHADE_ALPHA - a) / (MAX_SHADE_ALPHA - WARMTH_EASE_FROM) )
warmthAlpha = (warmth / 100) * MAX_WARMTH_ALPHA * headroom(shadeAlpha)
The binding constraint is (1 - WARMTH_EASE_FROM) × (1 - MAX_WARMTH_ALPHA) ≥ 1 - MAX_SHADE_ALPHA.
Sweeping the whole 101 × 101 grid, the worst composite is exactly 0.05, at dim 100 / warmth 0 —
equality, which is why §8’s float epsilon is load-bearing rather than defensive. With
MAX_WARMTH_ALPHA = 0.5f that constraint caps WARMTH_EASE_FROM at 0.90.
WARMTH_EASE_FROM = 0.88f, which leaves margin (0.06 against a 0.05 bound) and puts the
onset at dim level ≈ 90 with the backlight toggle on, ≈ 71 with it off. An earlier draft claimed
0.8f eased from ≈ 94; solving back through the shade curve, 0.8f eases from 84 and 54, and ≈ 94
is not reachable by any legal value of the constant.
The ease is defined over shadeAlpha, not over the dim level, and that is deliberate — the
invariant is stated over shadeAlpha, so easing over anything else would mean re-proving the bound
per toggle state. The consequence is that the dim level at which warmth starts to yield depends on
the toggle and on the user’s starting brightness. That is the same honesty §2 already owes about
“100 means as dark as currently allowed”, and it is stated rather than smoothed over.
A hard clamp instead of an ease would put a visible hue cliff in the last two points, which reads as a bug rather than as a limit. The ease is in the ramp, so the test covers it.
Halving the margin makes the amber’s luminance bound above a gate on the constants rather than a footnote, which is why it is asserted rather than measured.
Layer order and the amber itself
Amber above black. The order is irrelevant to the signal term — (1-a)(1-w) either way — but it
decides the amber’s own strength: below the black layer it is attenuated by (1-a) and warmth
becomes invisible exactly where a dark-adapted eye would notice it. Above, it arrives at full
strength. The reason that is safe here rather than a way to add light back at maximum dim is the
pair of bounds above: the veil is capped by the amber’s luminance, and by the time the black layer
is heavy the amber has already eased away.
The amber is a constant in shade/, not a MaterialTheme colour, and this is a deliberate
exception to the house rule. Three reasons, all of which distinguish it from every other colour in
the app: the shade is not a surface, it is a physical quantity chosen for its effect on light; it
must not change when the user switches the app’s own light/dark theme; and it is bounded by the
assertion above rather than by the palette’s contrast checks.
4. What the user sees
The dim screen grows from one slider and a button to three controls and a button. No new screen, no navigation change.
| Control | Notes |
|---|---|
| Dim level slider | Unchanged in shape. Its meaning changes: it now drives both mechanisms |
| Warmth slider | 0–100, default 0 |
| Also lower the screen brightness switch | Default on, per ADR-0010. Disabled with a reason when backlightTop is unavailable |
| Start / Stop dimming button | Unchanged, now behind the entry gate |
Warmth defaults to 0. A colour cast the user did not ask for is indistinguishable from a broken
screen, and it is the same argument that keeps DEFAULT_DIM_LEVEL modest. Warmth is worth having
because someone chooses it.
The switch cannot promise what the device will not do
When backlightTop is null — the read fails, the decode is out of range, or B’s verification
vetoed it — the ramp degrades cleanly to the shade-only case. The UI does not: the switch still says
Also lower the screen brightness, still defaults on, and dim_backlight_hint still promises the
user’s brightness slider will stop working. That is worse than saying nothing, and it is the
mirror image of the hint’s own justification — an explanation for a symptom that is not happening.
Whether backlightTop is obtainable is answerable by the screen, with no service and no override
applied: the resource resolves or it does not, the setting reads or it does not, the decoded float is
in range or it is not. All pure reads, re-read on resume beside canDraw. When it is false the
switch stays visible and disabled under a line saying so — visible rather than hidden, because it
explains why Gloam behaves differently here than on someone else’s phone.
Saying the inert brightness slider out loud
Rule 4’s third clause, and this phase owns it because this phase causes it. While the override is live the user’s own brightness slider moves and does not apply; whatever they set lands the moment the shade comes off. A user who meets that with no explanation reports that Gloam broke their phone, and nothing today connects the symptom to the switch that ends it.
It goes as supporting text under the switch — always present, not a one-off dialog, because the symptom recurs and a dismissed dialog is not there when it does. Draft copy, English, to be translated in the checkpoint that lands it:
| Key | English | Checkpoint |
|---|---|---|
dim_notification_warning_title |
No Stop button outside Gloam | A |
dim_notification_warning_body |
Gloam is allowed to dim your screen but not to show a notification, so the only way to stop it is this screen. | A |
dim_notification_warning_channel_body |
Gloam’s notification is switched off, so the only way to stop dimming is this screen. | A |
dim_notification_warning_ask |
Allow notifications | A |
dim_notification_warning_settings |
Open notification settings | A |
dim_backlight_label |
Also lower the screen brightness | C |
dim_backlight_hint |
Takes your screen’s own brightness down to its lowest before the shade starts, so Gloam has less light to cover. While it is on, your phone’s brightness slider will not do anything until you stop dimming. | C |
dim_backlight_unavailable |
This phone does not report its brightness in a way Gloam can use, so Gloam dims by drawing over the screen only. | C |
dim_warmth_label |
Warmth | D |
Translate from English only, per translator-brief.md, and once the wording
is settled rather than twice. Each checkpoint is a merge, so the gate enforces its own rows.
5. Storage
Two new keys in AppPreferences, each a key, a Flow reading it with its default, and a suspend
setter. No migration, ever — that is the point of the DataStore rule.
| Key | Type | Default | Note |
|---|---|---|---|
warmth |
Int |
0 |
0–100, coerced on read and on write like dim_level |
lower_backlight |
Boolean |
true |
ADR-0010’s toggle |
Both are written before the door, which is the last moment naming them is free: rule 2 freezes
every DataStore key that has reached a real phone. Name them right now — backlight is Android’s
value throughout CONTEXT.md, so lower_backlight says what it does without inventing a word.
Neither is affected by B’s veto: lower_backlight is the user’s preference, and a device that cannot
honour it is the disabled-switch case rather than a missing key.
The service currently collects dimLevel alone and now needs three values together. One combine,
one immutable DimSettings, one distinctUntilChanged, one apply — not three independent collectors
racing each other into updateViewLayout.
6. Documents this phase amends
CLAUDE.md— the composite house rule is already there. It names the warmth bound without giving it a name; fill inMAX_WARMTH_ALPHAandWARMTH_EASE_FROMonce they exist, and note that the bound is two invariants rather than one — the signal product and the amber’s luminance.CONTEXT.md— two edits.- Warmth currently says the shade is tinted amber, “cutting blue”. An overlay cannot subtract a channel (§3). Say what it does: adds amber over an already-dark screen, shifting the average colour rather than removing blue from the light underneath.
- Backlight top is a new entry, paired with the existing Floor: the backlight Gloam takes over from, captured at the moment the override is applied and held until it is released. It is not a device constant — it is user state, and after §2 it sets the shape of the whole slider.
- ADR-0010 — a second dated amendment is owed, per this file’s own rule that a measurement
contradicting the ADR gets an amendment rather than an edit. Three of its numbers did not survive
the phone:
- the “factor of ~75” is not a property of the device. The ratio is user state and reaches 1000 on this panel.
- “500 nits” was the user’s setting at the time, not the panel’s maximum. Float 1.0 is 2000 nits.
Settings.System.SCREEN_BRIGHTNESSreads 255 while the panel sits at its 2.0-nit floor. The ADR did not know that when it chose the window override. It does not reverse the decision —WRITE_SETTINGShas the same scale problem plus the stranding failure the ADR rejected it for — but it is material to it, and it is what checkpoint B exists to gate.
docs/index.md— says warmth tints the shade “as far or as little as you like”, which the composite bound makes untrue at the top of the dim range. One sentence. It is a public page that Play’s enforcement treats as part of the listing, so it gets fixed rather than tolerated.DOD.md— tick thePOST_NOTIFICATIONSitem at checkpoint A and dropNotificationPermission.ktfrom the scaffolding list.
7. Readings, and things that only look like readings
Rule 3: device behaviour is proven by measurement, and the phase writes down what was read and the
command that read it. Everything here needs the phone plugged in — adb devices first.
Rule 3 is about things read off a device, so the two kinds are kept apart. An earlier draft listed both under one heading, and at least one of its “measurements” was arithmetic wearing a command’s clothes.
Readings — off the phone
| # | Reading | Decides |
|---|---|---|
| R1 | The backlight sweep: float → nits, resolved finely near the bottom | how far geometric-in-float diverges from geometric-in-nits (§2) |
| R2 | Escape-hatch legibility at maximum dim — pull down the notification shade and tap Stop, in a dark room and under normal room light | MIN_BACKLIGHT |
| R3 | Computed backlightTop vs dumpsys mScreenBrightness, at four settings, mode manual |
checkpoint B’s verdict |
| R4 | The same, with screen_brightness_mode set to 1 deliberately |
whether adaptive forces the null path |
| R5 | Whose override applies when a system window is above the shade — lock screen, notification shade, volume dialog | what R2 is measured against; 3b’s go/no-go as a corollary |
| R6 | Is the override released on a ROM kill? am force-stop with the shade live |
ADR-0010’s rejection of WRITE_SETTINGS |
| R7 | Does the permission dialog work with the shade up? | how much Phase 2 has to work around |
| R8 | The notification and its Stop action appear, and stop the shade, at maximum dim | the escape hatch, end to end |
| R9 | Override vs an app setting its own screenBrightness — a video player in swipe-to-dim |
a stated use case; taken here because it is cheap here |
| R10 | API-33 AVD: launches, window appears, permission flow works | ADR-0008’s end-of-phase pass |
R5 was two items in an earlier draft — “whose override wins with a second window” filed as Phase
3b’s go/no-go, and “does the override survive screen off → on” filed separately. They are the same
reading. The system puts windows above TYPE_APPLICATION_OVERLAY constantly without any panel
existing, and the lock screen is the one the user meets every time they pick up the phone. Its answer
cuts both ways, which is why it belongs to Phase 1’s safety case rather than to 3b’s feasibility: if
system windows do lift the brightness, the escape hatch is legible while the content underneath
is not, and R2 gets much easier; if they do not, MIN_BACKLIGHT carries the whole safety
argument and R2 has to be passed against a hostile ambient case.
R1 has a shortcut worth knowing: dumpsys display publishes mBacklight/mNits for the panel, so
the curve does not need a meter — the sweep confirms the override honours it rather than discovering
it. The window override is per-window and only this app can set it, so the sweep needs a control
inside the app: a sweep row in app/src/debug/.../DebugSettings.kt, which is exactly what that
seam exists for and is currently an empty no-op. It never reaches a release build.
Derivations — arithmetic, and the test is the proof
Not readings. They are recorded here so nobody goes looking for a light meter, and each is asserted rather than observed.
| Quantity | How | |
|---|---|---|
| D1 | The composite at maximum dim | MIN_BACKLIGHT nits × (1 - MAX_SHADE_ALPHA). Nothing in dumpsys can see a view’s alpha |
| D2 | The light warmth adds | MAX_WARMTH_ALPHA × relativeLuminance(SHADE_AMBER) × backlight nits — §3’s second invariant, asserted at compile time |
| D3 | Where the backlight runs out for a given user | log(ratio) / (log(ratio) + log(span)) — §2 |
adb devices
adb shell dumpsys display | grep -E 'mBacklight=|mNits=|mScreenBrightness='
adb shell dumpsys display | grep -A2 OverrideBrightnessStrategy # override value + owning package
adb shell settings get system screen_brightness
adb shell settings get system screen_brightness_mode # 1 = adaptive
End of phase: R10, on the API-33 AVD (ADR-0008). Expect the emulator not to honour
screenBrightness in nits at all — that is fine and worth recording as a result rather than as a
failure. The phone stays the only place light is measured.
8. Tests
Three tests across the whole remaining roadmap, and this phase introduces the first. ShadeRampTest,
JVM, no Android — a table sweep over every input rather than a handful of examples, because the
inputs are 101 × 101 × a few and the JVM does not care.
Across all dim levels, all warmth values, the toggle both ways, and backlightTop at 1.0f, at
MIN_BACKLIGHT, below MIN_BACKLIGHT and null:
backlightisnull, or inMIN_BACKLIGHT … backlightTop. Never0.0f—BRIGHTNESS_OVERRIDE_OFF, the failure the constant exists to prevent.backlightis never greater thanbacklightTop. The ramp may not brighten.shadeAlphain0f … MAX_SHADE_ALPHA.warmthAlphain0f … MAX_WARMTH_ALPHA.- The signal invariant:
(1 - shadeAlpha) * (1 - warmthAlpha) ≥ 1 - MAX_SHADE_ALPHA, within a float epsilon. The worst case over the grid is exactly equal, so the epsilon is load-bearing. - The veil invariant:
MAX_WARMTH_ALPHA * relativeLuminance(SHADE_AMBER) ≤ 1 - MAX_SHADE_ALPHA. One assertion, no sweep — it is a property of the constants, and it is what stops a later amber change from passing every other assertion with an unreadable screen. - Monotonic: a higher dim level never emits more light —
backlightnon-increasing,shadeAlphanon-decreasing. True by construction in the new formulation, which is exactly why it is worth asserting: a fencepost intwould break it silently. - Continuous where the backlight runs out, for several ratios — no jump in either value. Also true by construction; it catches the fencepost, not a wrong breakpoint.
- The rate is constant end to end. Total light falls by the same ratio per point on both sides of the point where the backlight runs out. This is the assertion that pins the ramp’s shape, and it is the one the old fixed-breakpoint design could not make.
- Dim level 0 releases the override and leaves
shadeAlphaat 0, both toggle states. backlightTop == nulland toggle-off produce identical output. One branch, proven.
Everything else in this phase is device behaviour and belongs in §7. There is no instrumented test
here: app/src/androidTest is empty and the three emulator CI legs are vacuously green, which
DOD.md records as Phase 2’s problem to either fix or cut. Phase 1 does not adopt it.
9. The commit sequence
Conventional Commits, and each one leaves the app working — feat: lines land in CHANGELOG.md
through release-please, so they are written for someone reading the release notes. chore: and
test: do not, which is what decides the type below rather than taste.
| Checkpoint | Commits |
|---|---|
| A | feat: ask for notification permission before the first shade — the gate, the stateless first-ask rule, the live-read warning and its four buttons, plus the strings in both locales |
| B | chore: add a backlight sweep to the debug build — developer surface, no release note. Then the readings, which are not a commit |
| C | chore: add the shade ramp + test: bound the shade ramp across every dim level — the pure function and its table sweep, wired to nothing. Then feat: take the backlight down with the dim level — the override on the window, the lower_backlight key, the toggle and its two strings. MIN_BACKLIGHT gets its measured value here |
| D | feat: tint the shade amber — the FrameLayout, the second child, the warmth key and slider, SHADE_AMBER, and both invariants added to the ramp test |
| E | docs: ... — §6’s edits, ADR-0010’s second amendment, and this file’s readings block filled in |
The ramp is chore:, not feat:. It lands wired to nothing — the cheapest possible place to get
the maths wrong — and a feat: line would put “add the shade ramp” under Features in the release
notes of a version where nothing observable changed, plus a minor bump for it. The feature is the
commit that wires it up, and that one says what a user would notice.
The debug sweep row lands at B and stays; it is developer surface in app/src/debug/ by the house
rule, never in main/ behind BuildConfig.DEBUG.
10. Recruiting the twelve testers starts here
Not a build task, and it is in this phase because it is the longest lead item in the plan and it is calendar time rather than effort. Production access needs twelve testers opted in continuously for fourteen days, confirmed on 2026-08-30 to apply to this app.
Recruit more than twelve — fifteen or more. The window wants twelve opted in continuously, and
one person uninstalling in week two is the failure the surplus exists to absorb. What they need is
a Google account and the opt-in link from the Play Console’s closed track; what they will judge, per
rule 5, is the first two minutes. Track the count in DOD.md, which already carries the item.
Kotlin and Compose notes for this phase
combineof threeFlows isPromise.allover streams that never finish: it emits a new tuple every time any input changes, so the service gets oneDimSettingsrather than three callbacks it has to reconcile.distinctUntilChangedafter it is what stops an unrelated preference write from re-applying the window layout.Float?rather than a-1fsentinel. Android’s own API usesBRIGHTNESS_OVERRIDE_NONE = -1fbecause Java had no other way to say “absent”. Kotlin does, andnullcannot be accidentally arithmetic’d into a ramp the way-1fcan. Convert to the sentinel at the one place that talks toLayoutParams, exactly like a discriminated union collapsing to a wire format at the boundary.- A window attribute is not a view property.
view.alpha = xtakes effect on its own;params.screenBrightness = xdoes nothing untilwindowManager.updateViewLayout(view, params). Keep theLayoutParamsinstance the window was added with, mutate it, and update — do not build a fresh one, or every flag has to be re-stated correctly every time. data classwith acopy()is object spread with the compiler checking the field names, which is whyDimSettingsis a data class and not three fields on the service.- A permission launcher is asynchronous, and that changes the call order rather than adding a
callback to the end of it.
startShade()andsetRunning(true)both move inside the outcome lambda — closer toawaitthan to a fire-and-forget.then(), because everything after the ask depends on its answer. - Compile-time constants are safe in a JVM unit test, method calls on
android.jarare not — the stub throws “not mocked”. KeepingShadeRamp.ktfree of Android imports makes that a non-question rather than a thing to remember.relativeLuminanceis arithmetic over three floats, so §8’s veil assertion stays on the JVM too; do not reach forColorUtils.
Readings block
Rule 3: filled in as the phase runs, from the device, with the command that read it. Not “it looked
right”. Derivations are in section 7 and are proven by ShadeRampTest, not recorded here.
Read with the screen held awake. Four of these were first taken against a screen inside its
inactivity timeout, which pins the panel to its floor under mBrightnessReason=manual [ dim ] and
looks exactly like “the setting did nothing”. The debug sweep holds FLAG_KEEP_SCREEN_ON; taking
anything by hand wants settings put system screen_off_timeout 600000 first, and putting it back
after.
| # | Reading | Command | Result |
|---|---|---|---|
| R1 | Nits per screenBrightness float, resolved near the bottom |
debug sweep + dumpsys display |
nits = 498.3 x override + 1.66 across 16 steps, 1.0 down to 1E-4. Affine, not a power law; the override clamps to the panel floor at and below 6.83661E-4 (2.0 nits). See below for what it costs the ramp |
| R2 | Stop reachable at maximum dim, dark room and lit room -> MIN_BACKLIGHT |
by hand, on the phone, with the shade live at dim 100 | MIN_BACKLIGHT = 0.01f, which is 6.64 nits on this panel. Read in a lit room and in a dark one; legible in both. Taken against the shade’s own window override (mWindowManagerBrightnessOverrideTag=io.github.srednimax.gloam.debug, no Activity suffix), not the activity’s |
| R3 | Computed backlightTop vs actual, four settings, manual mode -> B’s verdict |
debug row + dumpsys display |
Passed. 1.2% to 5.0% under the user’s own float at raw 10/20/64/128/255; never over. Table in section 2 |
| R4 | The same with adaptive deliberately on | settings put system screen_brightness_mode 1 |
Adaptive is an ordinary read. The framework stores its own choice back into the same integer (raw=14 at 6.3 lux, 27.2 nits); decoded 3.8% under. No null path needed |
| R5 | Whose override applies - lock screen, notification shade, volume dialog | cmd statusbar expand-notifications / expand-settings / input keyevent VOLUME_UP, each followed by dumpsys display |
Ours wins everywhere except the keyguard. The notification shade, quick settings and the volume dialog all read back mWindowManagerBrightnessOverride=0.01 unchanged - so MIN_BACKLIGHT carries the whole escape-hatch argument, the hostile branch. The lock screen releases it outright (NaN, reason=manual, back to the user’s own 250.7 nits) and it returns by itself on unlock |
| R6 | Override released on a ROM kill | am force-stop with the shade live |
Released, on the shade’s own window (re-confirmed at C; B had only the activity’s). Holding 6.64 nits at dim 100, am force-stop returned the panel to the user’s own 250.7 nits and reason=manual within a second |
| R7 | Permission dialog usable with the shade up | pm revoke com.xiaomi.scanner CAMERA, launch it with the shade live, then dumpsys window windows + dumpsys display |
Usable, and the platform is what makes it so. With another app’s dialog up and the shade live at dim 98, the shade’s window stays registered but goes mPolicyVisibility=false mForceHideNonSystemOverlayWindow=true, and the override goes with it - mWindowManagerBrightnessOverride=NaN, the panel back to the user’s own 0.42695385 - although the window still carries sbrt=0.01. The dialog’s buttons read clickable=true and the tap landed the grant. Both come back by themselves when it closes, same window, no service restart |
| R8 | Notification and Stop appear and work, at maximum dim | dumpsys notification --noredact, then by hand |
Both, and Stop genuinely stops it - tapping it released the override and the panel returned to the user’s own 250.7 nits, service gone. Notification(channel=shade ... flags=ONGOING_EVENT\|NO_CLEAR\|FOREGROUND_SERVICE\|SILENT actions=1), title Screen dimmed, undismissable. But reaching Stop on HyperOS takes a long-press - see below |
| R9 | Override vs an app setting its own screenBrightness |
MIUI’s local player, swipe-to-dim on the left edge, then dumpsys window windows for sbrt= on both windows |
Ours wins, and in both directions. The player does set its own window brightness - sbrt=1.0 after swiping up, 0.007843138 after swiping down, either side of our 0.01 - and neither reached the panel: mWindowManagerBrightnessOverride stayed 0.01 tagged io.github.srednimax.gloam.debug throughout. It is the topmost window’s value that applies, not the lowest one |
| R10 | API-33 AVD: launches, window appears, permission flow works | emulator -avd gloam-api33 -no-window, then dumpsys window windows and dumpsys display |
All three, and it honours more than was expected. Pixel 6 profile, google_apis x86_64, headless: the app launches, the ask fires before the shade, Allow lands granted=true, and the shade window comes up ty=APPLICATION_OVERLAY with NOT_FOCUSABLE NOT_TOUCHABLE LAYOUT_IN_SCREEN LAYOUT_NO_LIMITS intact. The override applies - mBrightnessReason=override, and Display Brightness follows the window’s sbrt to 0.01 at dim 99 |
What R8 found about the escape hatch, which is the phase’s own safety claim. CLAUDE.md says
the foreground notification is the escape hatch. It is - but the Stop action is not visible in
the collapsed notification on this ROM. HyperOS renders the row as title and text only, a downward
swipe does not expand it, and the button appears in the long-press overlay. A plain tap follows
the content intent to MainActivity, which at maximum dim is under the shade at
6.64 nits x (1 - MAX_SHADE_ALPHA) = 0.33 nits - so the app’s own Stop dimming button is the
one escape hatch that the backlight half made hard to see, while the notification’s action is drawn
by SystemUI above TYPE_APPLICATION_OVERLAY and stays at the full 6.64.
Recorded rather than worked around, deliberately. It reads as vendor behaviour rather than a
Gloam defect: stock Android auto-expands the top notification in the shade, which shows the action,
and there a long-press opens the channel’s settings instead. Copy naming a gesture would therefore be
wrong on the majority of devices, and a custom collapsed RemoteViews is the notification API’s
most ROM-fragile corner - a worse trade than the thing it fixes. Phase 2b’s Quick Settings tile is
the one-gesture escape hatch and this is the measurement that says why it is not a nicety. Phase 3a
should note the other half: a control surface that lives under the shade it controls is only as
legible as the shade allows.
What R7 and R9 together settle: the brightness belongs to the topmost window, and it is the same
rule from both sides. R5 asked the question for the system’s own surfaces; R9 asks it for an
ordinary app window, and the answer does not change. The shade is TYPE_APPLICATION_OVERLAY, which
sits above every activity, so an app that sets its own screenBrightness is simply not consulted —
the player asking for 1.0 and then for 0.0078 got neither, and it is the topmost value that
applies rather than the lowest. That is the stated use case working, and it has a cost worth
naming rather than discovering: an app that legitimately needs a bright screen cannot have one
while the shade is up — a QR code, a boarding pass, a payment code. Gloam does not break that, it
defers it, exactly as it defers the user’s own brightness slider; and the way out is the one R8
already pointed at, a stop that takes one gesture from wherever the user is. Phase 2b’s tile.
R7 is the second way the override goes away, and like the keyguard it is the system taking the
window rather than out-ranking it. A runtime permission dialog force-hides every non-system overlay — mPolicyVisibility=false,
mForceHideNonSystemOverlayWindow=true — which exists precisely so an app cannot draw over a
consent prompt. A hidden window owns no brightness, so sbrt=0.01 stays on our LayoutParams and
the panel goes back to the user’s own setting for exactly as long as the dialog is up, then both
return with no service restart. Phase 2 has nothing to work around here: whatever raises a
dialog, it arrives on an undimmed screen and leaves the shade as it found it. §1’s rule is
untouched by this and deliberately does not lean on it — askForNotifications() takes the shade
down itself, so what R7 measures is other apps’ dialogs, which is the half Gloam cannot control.
What R10 says, and the one thing it cannot. Section 7 expected the emulator not to honour
screenBrightness at all. It does, and it tracks the window’s value exactly. What it has no answer
for is nits: DisplayDeviceConfig reads mBacklight=null, mNits=null with an identity
brightness-to-backlight spline, so the float is applied with nothing photometric behind it. That is
the reason the phone stays the only place light is measured, made concrete rather than assumed. Two
smaller things worth having from it. Its own mBacklightMinimum is 0.035433073, above
MIN_BACKLIGHT — the first display seen where the ramp’s floor is under what the panel can
represent, which the framework simply clamps, and which is the harmless direction rather than the
BRIGHTNESS_OVERRIDE_OFF one the constant exists to prevent. And the composite is checkable there
without a meter: screencap at dim 99 has mean RGB (64.1, 61.3, 58.3) against (243.9, 234.0,
222.0) with the shade stopped. The ratio is 0.263, and 1 - MAX_SHADE_ALPHA is 0.05 in linear
light, which re-encodes to sRGB as 0.05 ^ (1/2.2) = 0.25. The cap ShadeRampTest asserts on the
JVM is the cap that reaches a screen.
What R1 costs the ramp, which is a correction and not a reassurance. The offset is the whole
story: nits = 498.3 x u + 1.66 means the float range’s bottom decade buys almost nothing. Halving
the override from 0.002 to 0.001 is a 50% cut in float and a 19% cut in light (2.66 nits to
2.16). So a ramp that is geometric in the float is not geometric in what the eye receives, and it
flattens exactly where this product lives. The honest ratio the backlight can spend at full
brightness is 250:1 in nits, not the 1462:1 the floats suggest. Checkpoint C owns what to do
about it; the two constants that would fix it exactly (498.3 and 1.66) are this panel’s and are
not readable from an app.
Checkpoint C’s answer: measured, and left alone. The correction would be a fifth constant — the
offset as a fraction of the span, 1.66 / 498.3 — carried on every device to fix a distortion that
the readings say is small. Over the whole slider the shortfall in perceptual travel is under 4%
at either end of the user’s own brightness range: the backlight stretch delivers 94% of the ratio it
promises for a user at maximum and 80% for one already at their system minimum, and the shade half is
exactly geometric because alpha genuinely multiplies. What makes it cheap to decline is
MIN_BACKLIGHT: the affine offset only bites in the float’s bottom decade, and R2 put the floor at
0.01 — 6.64 nits — which stops the ramp well above it. Recorded in ShadeRamp.kt beside the ramp
rather than only here, because that is where somebody would go to re-open it.
Already read, 2026-08-30, on the phone - the readings that reshaped this document, kept here because sections 2 and 6 both argue from them:
| Reading | Value | Command |
|---|---|---|
| The panel’s float -> nits curve | [6.83661E-4, 0.499951, 0.99975586, 1.0] -> [2.0, 500.0, 1200.0, 2000.0] |
dumpsys display \| grep mBacklight |
| The system’s minimum float | 6.83661E-4 - exactly 2.0 nits |
dumpsys display \| grep RangeMinimum |
SCREEN_BRIGHTNESS at that floor: 255, with the panel at 2.0 nits |
Withdrawn 2026-08-30. The panel was in its inactivity DIM policy, not at the user’s setting. raw 255 is 500 nits; the two never coincide on this phone |
settings get system screen_brightness |
| The user’s own floor | raw 10 -> 19 nits, and their slider goes no lower |
settings put system screen_brightness 10 |
| The panel’s non-HBM maximum | 0.499951 - 500 nits, which is where both raw 255 and override 1.0 land |
dumpsys display \| grep hbmMax |
screen_brightness_float |
null - no free float read on this ROM |
settings get system screen_brightness_float |
Done when
- The entry gate asks once, spends the second denial only on a deliberate tap, and the notification with its working Stop action is on the phone.
- The warning is a live read of whether that notification can actually appear, and it clears itself when the user fixes it in settings.
- One dim level drives backlight then shade at a constant ratio per point, warmth tints it, and both invariants are asserted by a test that sweeps every input.
- The four constants and the amber have measured or asserted values and the comments that say why none of them is a preference.
- The switch says out loud what it does to the user’s own brightness slider — or says why it cannot,
on a device where
backlightTopis not trustworthy — in both languages. - The readings block above has no dashes left in it, including the two taken for Phase 3b’s go/no-go rather than for this phase.
- Or: checkpoint B vetoed the backlight half, C was skipped, and the phase closed with that written down here rather than argued about later.
Phase 1 closed 2026-08-31, on the wide branch rather than the narrow one. B did not veto, so the backlight half shipped and one dim level drives all three mechanisms. Every row in the readings block above has a result behind it, including the two taken for later phases rather than for this one.