@tenphi/glaze 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/hdr.mdx ADDED
@@ -0,0 +1,523 @@
1
+ ---
2
+ title: HDR glows
3
+ description: Glows past SDR white for HDR displays — when Glaze adds an HDR value, how to opt in, and where it can be shown today.
4
+ ---
5
+
6
+ import Example from '../docs-site/components/Example.astro';
7
+ import GlowsPastWhite from '../docs-site/components/shadows-and-glows/GlowsPastWhite.tsx';
8
+ import { Preview } from '@tenphi/cookbook/components';
9
+ import { hdrHaloPreview } from '../docs-site/components/shadows-and-glows/hdr-preview.ts';
10
+
11
+ export const hdrTheme = {
12
+ hue: 280,
13
+ saturation: 80,
14
+ config: { glowTuning: { headroom: 1 } },
15
+ colors: {
16
+ surface: { tone: 100 },
17
+ 'surface-glow': { type: 'glow', bg: 'surface', intensity: 12 },
18
+ near: { tone: 95, saturation: 0, mode: 'static' },
19
+ 'near-glow': { type: 'glow', bg: 'near', intensity: 12 },
20
+ inverse: { tone: 12, saturation: 0, mode: 'static' },
21
+ 'inverse-glow': { type: 'glow', bg: 'inverse', intensity: 12 },
22
+ },
23
+ };
24
+
25
+ export const haloTheme = {
26
+ hue: 280,
27
+ saturation: 80,
28
+ config: { glowTuning: { headroom: 1 } },
29
+ colors: {
30
+ page: { tone: 92, saturation: 0.1 },
31
+ card: { base: 'page', tone: '+6', saturation: 0.1 },
32
+ 'card-glow': { type: 'glow', bg: 'page', fg: 'card', intensity: 20 },
33
+ },
34
+ };
35
+
36
+ export const haloPreview = hdrHaloPreview(haloTheme, {
37
+ page: 'page',
38
+ card: 'card',
39
+ glow: 'card-glow',
40
+ blur: 40,
41
+ spread: 4,
42
+ });
43
+
44
+ A [glow](./shadows-and-glows.mdx) can't go past white in SDR, but an HDR
45
+ display can show brighter than white. Set `glowTuning.headroom`, and a glow
46
+ whose step would pass white also resolves an HDR value: an opaque exposure of
47
+ what SDR already paints there. The SDR value doesn't change, and exporters add
48
+ the HDR one only when you [opt in](#output-is-opt-in). Everything outside this
49
+ page is SDR; skip it unless you target HDR displays.
50
+
51
+ ## When a glow gets an HDR value
52
+
53
+ [Tone runs on past 100](./okhst.mdx#tone-above-100) on the same curve: one
54
+ stop above SDR white (`Y` 2) is tone 121.98, two stops (`Y` 4) tone 144.34,
55
+ and 12 tones past white is the same `Y + 0.05` ratio as a 12-tone step
56
+ anywhere. WCAG and APCA end at white, so Glaze makes no contrast claim above
57
+ 100. A glow can't be a `bg`, `fg`, `base`, or mix input, so an HDR value never
58
+ reaches the contrast solver.
59
+
60
+ A glow gets an HDR value when three things hold: its `headroom` is above 0,
61
+ its target tone (the surface's luminance tone plus `intensity × k`) passes
62
+ 100, and SDR is pinned at its brightest. That means the pigment at full
63
+ opacity, or a white surface (tone 100) at opacity 0, since no pigment can lift
64
+ it. Below white nothing changes, dark-scheme glows included. A pigment that
65
+ can't lift a non-white surface at all (`reach: 0` on a gray surface, say)
66
+ paints nothing in SDR, gets no HDR value, and keeps the SDR warning. On a
67
+ chromatic surface a `reach: 0` pigment, a gray at the surface's tone, can be
68
+ slightly brighter than the surface: it then pins at full opacity and, past
69
+ white, gets an HDR value like any pinned pigment.
70
+
71
+ The resolved variant gains `hdr: { h, s, t, exposure }`. The base (`h`, `s`,
72
+ `t`) is the opaque color SDR paints, with a standard (never pastel) OKHSL
73
+ saturation. The HDR value is that base's linear light times `2^exposure`, the
74
+ gain that lands the target tone exactly; `variant.t` itself stays at most 1. The value is
75
+ opaque on purpose: it stays exact whichever way it is composited, and it maps
76
+ one to one onto native exposure APIs.
77
+
78
+ ## Headroom
79
+
80
+ `headroom` counts stops above SDR white, the doublings of its luminance. It
81
+ defaults to 0 (no HDR anywhere) and must be a finite number ≥ 0, or Glaze
82
+ throws. Set it globally (`glaze.configure({ glowTuning: { headroom: 1 } })`),
83
+ in a theme's config override, on one glow's `tuning`, or on
84
+ `glaze.glow()`. A glow's `tuning` merges over `glowTuning` field by field. A
85
+ theme's own `glowTuning` replaces the global one whole, and so does a later
86
+ `glaze.configure({ glowTuning })`, so pass every field you want kept:
87
+ `glaze.configure({ glowTuning: { objectInfluence: 0.6, headroom: 1 } })`. A
88
+ `headroom` on a shadow throws: nothing goes below black.
89
+
90
+ Values above 2 clamp to 2. The warning fires once per field and value, and
91
+ only after the whole input has validated:
92
+
93
+ ```text
94
+ glaze: glowTuning.headroom 4 is above the 2-stop maximum; using 2.
95
+ Headroom counts stops (doublings of SDR white's luminance), not a
96
+ ratio: 1 stop is 2× white, 2 stops 4×.
97
+ ```
98
+
99
+ The gain is capped per channel, so no linear channel of the HDR value passes
100
+ `2^headroom`. On a neutral glow that caps luminance: a 30-tone glow on white
101
+ with 1 stop of headroom ends at `color(srgb 1.3533 1.3533 1.3533)` (linear 2
102
+ per channel), tone 121.98, and warns (below). A saturated base hits the cap on
103
+ its brightest channel first, so its HDR tone can stay below 100.
104
+
105
+ How much of that a display shows depends on its headroom at the moment, which
106
+ falls as the user turns brightness up. Past it, most platforms clip the
107
+ color, which can move a tinted glow toward white, while Apple's platforms
108
+ tone-map it. A web page can't read the number. Android suggests about 1.5–2×
109
+ white for interfaces that are mostly SDR, so 1 stop is a sensible ceiling for
110
+ UI.
111
+
112
+ With headroom, a glow whose target passes white no longer logs the
113
+ [unreachable-step warning](./shadows-and-glows.mdx#unreachable-steps): its HDR
114
+ value lands the step. Only the headroom cap can leave such a step short, and
115
+ that logs its own warning. A 30-tone glow on white with 1 stop of headroom:
116
+
117
+ ```text
118
+ glaze: glow "surface-flare" cannot reach a 30.0-tone step in light
119
+ scheme even with 1 stop of headroom (reached 22.0).
120
+ Lower the intensity or raise `headroom`.
121
+ ```
122
+
123
+ At the 2-stop maximum the second sentence reads "Lower the intensity;
124
+ `headroom` is already at its 2-stop maximum." A glow without an HDR value
125
+ keeps the SDR warning.
126
+
127
+ ## Worked example
128
+
129
+ Three 12-tone glows: one on a white `auto` surface, one on a light gray, and
130
+ one on a dark gray.
131
+
132
+ <Example
133
+ {...hdrTheme}
134
+ visual="none"
135
+ after={
136
+ "const glow = theme.resolve().get('surface-glow')!.light;\n" +
137
+ 'glaze.formatHdr(glow);\n' +
138
+ "// → 'color(srgb 1.1813 1.1813 1.1813)'"
139
+ }
140
+ />
141
+
142
+ <GlowsPastWhite
143
+ {...hdrTheme}
144
+ glows={['surface-glow', 'near-glow', 'inverse-glow']}
145
+ caption="Each glow over its surface, in your current scheme, as CSS paints it: the step it reached, the step asked, and its opacity. The HDR values are in the table below."
146
+ />
147
+
148
+ The same numbers in every scheme:
149
+
150
+ | Glow | SDR | HDR |
151
+ | --- | --- | --- |
152
+ | `surface-glow`, light and light HC | White at α 0 | 1.1813 per channel (linear 1.4631), 0.549 stops, tone 112 |
153
+ | `surface-glow`, dark and dark HC | White at α 0.1237 (α 0.1603 in dark HC) | None: the dark surface has room |
154
+ | `near-glow`, all four | Opaque white, 5 of 12 tones | 1.1026 per channel (linear 1.2494), 0.321 stops, tone 107 |
155
+ | `inverse-glow`, all four | White at α 0.1155 | None: the target is tone 24 |
156
+
157
+ `near-glow` shows the split: SDR pins it to opaque white 7 tones short, and
158
+ its HDR value lands those 7. In dark, `surface` sits at tone 9.25 (luminance
159
+ tone 8.5, so the glow's target is tone 20.5), and `surface-glow` is an
160
+ ordinary translucent glow there.
161
+
162
+ ## Crossing white
163
+
164
+ A full-reach glow meets SDR at the boundary. On a gray tone-95 surface (like
165
+ `near`) a 4.9-tone glow is white at α 0.9794 and a 5-tone glow is opaque
166
+ white, both without an HDR value. At 5.1 tones it adds
167
+ `color(srgb 1.0014 1.0014 1.0014)`, 0.0046 stops.
168
+
169
+ A glow with `reach` below 1 doesn't meet it. Its pigment sits below white, so
170
+ it stays short and warns until its target passes 100, then its HDR value lands
171
+ the whole step at once. A `reach: 0.5` glow on a gray tone-90 surface
172
+ paints tone 95 for any step from 5 to 10. Past 10, its HDR value jumps straight to
173
+ tone 100 (0.2315 stops over the pigment). Keep `reach` at 1 on a glow that
174
+ may cross white.
175
+
176
+ ## Extended sRGB
177
+
178
+ Glaze writes an HDR value as `color(srgb R G B)` with channels past 1: the
179
+ sRGB curve continued past SDR white, so `1` is white and one stop above it is
180
+ `1.3533`. `glaze.formatHdr(variant, 'srgb-linear')` writes the same color in
181
+ linear light (`color(srgb-linear 1.4631 1.4631 1.4631)` for `surface-glow`),
182
+ and a browser converts either one into the other exactly. The format doesn't
183
+ follow `format`: CSS clamps `oklch()` lightness at 1, and `rgb()` / `hsl()`
184
+ can't go past white.
185
+
186
+ That is all an HDR glow needs. Its value is its SDR base made brighter, so it
187
+ stays inside the sRGB gamut and is measured against SDR white, the way
188
+ displays and native APIs measure headroom. The `rec2100-pq` and
189
+ `rec2100-hlg` spaces from the CSS Color HDR draft would add a conversion, to
190
+ wider primaries and to an HDR transfer curve pinned to an assumed SDR white,
191
+ with nothing to gain for a color like this, and browsers don't support them
192
+ yet.
193
+
194
+ The same numbers are what most HDR APIs take as they are: a WebGPU `'srgb'`
195
+ canvas, Apple's extended sRGB color space, and Android's `EXTENDED_SRGB`. For
196
+ numbers instead of a string, `hdrToSrgb(variant.hdr)` returns these channels,
197
+ and `hdrToLinearSrgb(variant.hdr)` the linear ones, for APIs that take linear
198
+ light.
199
+
200
+ ## Output is opt-in
201
+
202
+ Resolving fills in `variant.hdr` whenever headroom allows. Exporters leave it
203
+ out until you turn on `modes.hdr`, so every export is byte-identical until
204
+ then, headroom or not. Turn it on for the export that needs it:
205
+
206
+ ```ts
207
+ const theme = glaze(280, 80, { glowTuning: { headroom: 1 } });
208
+
209
+ theme.colors({
210
+ surface: { tone: 100 },
211
+ 'surface-glow': { type: 'glow', bg: 'surface', intensity: 12 },
212
+ near: { tone: 95, saturation: 0, mode: 'static' },
213
+ 'near-glow': { type: 'glow', bg: 'near', intensity: 12 },
214
+ });
215
+
216
+ theme.dtcg({ modes: { hdr: true } });
217
+ theme.css({ hdr: true }); // css() has no `modes`
218
+ // Or for every export:
219
+ glaze.configure({ modes: { hdr: true } });
220
+ ```
221
+
222
+ Each export then adds the HDR value next to the SDR value it already emits,
223
+ for the schemes the other `modes` include. `surface-glow` has one in the light
224
+ schemes only; on the dark surface its step stays below white. `near-glow` has
225
+ one in all four. HDR values stay literal under `splitHue`, palettes treat them
226
+ like any other entry (prefixes, the `primary` alias, collisions), and
227
+ `glaze.color()` tokens are never glows, so the HDR options don't change their
228
+ output.
229
+
230
+ The data exports (DTCG, `json()`, `tokens()`) are what native apps and
231
+ WebGPU code read. The CSS exports (`css()`, `tasty()`, Tailwind) are
232
+ experimental: no browser paints them past white yet
233
+ ([Browsers](#browsers)), and their shape may change once one does. Prefer
234
+ turning HDR on per call, so a setup that only needs DTCG doesn't add the CSS
235
+ tier to every stylesheet.
236
+
237
+ ### CSS blocks
238
+
239
+ `css()` takes `hdr` (default: the global `modes.hdr`) and returns four more
240
+ blocks in `hdr`. Wire each one after the SDR blocks, under the same selector
241
+ plus `(dynamic-range: high)`:
242
+
243
+ ```ts
244
+ const css = theme.css({ hdr: true });
245
+ const hdr = css.hdr!;
246
+ const dark = '(prefers-color-scheme: dark)';
247
+ const more = '(prefers-contrast: more)';
248
+ const high = '(dynamic-range: high)';
249
+
250
+ const stylesheet = `
251
+ :root { ${css.light} }
252
+ @media ${dark} { :root { ${css.dark} } }
253
+ @media ${more} { :root { ${css.lightContrast} } }
254
+ @media ${dark} and ${more} { :root { ${css.darkContrast} } }
255
+
256
+ @media ${high} { :root { ${hdr.light} } }
257
+ @media ${dark} and ${high} { :root { ${hdr.dark} } }
258
+ @media ${more} and ${high} { :root { ${hdr.lightContrast} } }
259
+ @media ${dark} and ${more} and ${high} {
260
+ :root { ${hdr.darkContrast} }
261
+ }
262
+ `;
263
+ ```
264
+
265
+ The blocks are dense. Each lists every color that has an HDR value in any
266
+ scheme, and repeats a color's SDR declaration where its own scheme has none:
267
+
268
+ ```css
269
+ /* hdr.light */
270
+ --surface-glow-color: color(srgb 1.1813 1.1813 1.1813);
271
+ --near-glow-color: color(srgb 1.1026 1.1026 1.1026);
272
+
273
+ /* hdr.dark */
274
+ --surface-glow-color: oklch(1 0 0 / 0.1237);
275
+ --near-glow-color: color(srgb 1.1026 1.1026 1.1026);
276
+ ```
277
+
278
+ On a dark HDR display, both the light and the dark HDR blocks match. The dark
279
+ one comes later, so its `surface-glow` line puts the dark SDR value back. A
280
+ block that listed only HDR values would let the light one leak into dark.
281
+ When no glow passes white, all four blocks are empty strings.
282
+
283
+ ### Tasty keys
284
+
285
+ `tasty()` adds a key under the `states.hdr` alias (default
286
+ `'@media(dynamic-range: high)'`) right after each SDR key that has an HDR
287
+ value:
288
+
289
+ ```ts
290
+ theme.tasty({ modes: { hdr: true } })['#surface-glow'];
291
+ // → {
292
+ // '': 'oklch(1 0 0 / 0)',
293
+ // '@media(dynamic-range: high)': 'color(srgb 1.1813 1.1813 1.1813)',
294
+ // '@media(prefers-color-scheme: dark)': 'oklch(1 0 0 / 0.1237)',
295
+ // }
296
+ ```
297
+
298
+ Tasty gives later keys priority, so the order is what keeps the light HDR
299
+ value out of dark: `''`, hdr, dark, dark & hdr, hc, hc & hdr, dark & hc,
300
+ dark & hc & hdr, with only the keys that exist. Don't sort the keys if you
301
+ post-process the map. With your own aliases, set `states.hdr` next to `dark`
302
+ and `highContrast` (for example `'@hdr'`, which emits keys such as
303
+ `'@dark & @hdr'`) and register it in Tasty like the others.
304
+
305
+ ### Tailwind blocks
306
+
307
+ `tailwind()` appends the same dense blocks after all its SDR blocks, under
308
+ `hdrSelector` (default `'@media (dynamic-range: high)'`), around
309
+ `darkSelector` and `highContrastSelector`:
310
+
311
+ ```css
312
+ /* ...the @theme and .dark blocks, unchanged... */
313
+
314
+ @media (dynamic-range: high) {
315
+ :root {
316
+ --color-surface-glow: color(srgb 1.1813 1.1813 1.1813);
317
+ --color-near-glow: color(srgb 1.1026 1.1026 1.1026);
318
+ }
319
+ }
320
+
321
+ @media (dynamic-range: high) {
322
+ .dark {
323
+ --color-surface-glow: oklch(1 0 0 / 0.1237);
324
+ --color-near-glow: color(srgb 1.1026 1.1026 1.1026);
325
+ }
326
+ }
327
+ ```
328
+
329
+ ### JSON and tokens
330
+
331
+ `json()` adds a sparse `<scheme>Hdr` key after each scheme that has an HDR
332
+ value. `tokens()` adds a dense `<scheme>Hdr` map after each scheme it emits,
333
+ always present and possibly empty:
334
+
335
+ ```ts
336
+ theme.json({ modes: { hdr: true } })['surface-glow'];
337
+ // → {
338
+ // light: 'oklch(1 0 0 / 0)',
339
+ // lightHdr: 'color(srgb 1.1813 1.1813 1.1813)',
340
+ // dark: 'oklch(1 0 0 / 0.1237)',
341
+ // }
342
+
343
+ theme.tokens({ modes: { hdr: true } }).darkHdr;
344
+ // → {
345
+ // 'surface-glow': 'oklch(1 0 0 / 0.1237)',
346
+ // 'near-glow': 'color(srgb 1.1026 1.1026 1.1026)',
347
+ // }
348
+ ```
349
+
350
+ A dense map drops into a cascade the way the CSS blocks do; the sparse JSON
351
+ keys suit code that reads one color at a time. Both carry only the string,
352
+ whose numbers are the [extended sRGB](#extended-srgb) channels. Apple's
353
+ exposure initializers take a base and an exposure, which only DTCG carries.
354
+
355
+ ### DTCG
356
+
357
+ `dtcg()` and `dtcgResolver()` keep `$value` SDR: DTCG color components run
358
+ from 0 to 1. The HDR value goes in `$extensions`, only in the scheme
359
+ documents that have one. This is `light['surface-glow']`;
360
+ `dark['surface-glow']` has no `$extensions`:
361
+
362
+ ```json
363
+ {
364
+ "$type": "color",
365
+ "$value": {
366
+ "colorSpace": "srgb",
367
+ "components": [1, 1, 1],
368
+ "hex": "#ffffff",
369
+ "alpha": 0
370
+ },
371
+ "$extensions": {
372
+ "me.tenphi.glaze": {
373
+ "hdr": {
374
+ "exposure": 0.548987,
375
+ "tone": 112,
376
+ "base": {
377
+ "colorSpace": "srgb",
378
+ "components": [1, 1, 1],
379
+ "hex": "#ffffff"
380
+ },
381
+ "value": {
382
+ "colorSpace": "srgb",
383
+ "components": [1.181265, 1.181265, 1.181265]
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+ ```
390
+
391
+ - `base` is the opaque SDR color the exposure applies to: always a DTCG sRGB
392
+ color with `hex`, whatever `colorSpace` you export, and never pastel.
393
+ - `exposure` is the gain over `base` in stops.
394
+ - `value` is `base × 2^exposure` in linear light, written as extended sRGB:
395
+ the numbers of the CSS string, to 6 decimals. Only its brightest component is guaranteed above 1.
396
+ - `tone` is the HDR value's tone: past 100 when the headroom covers the step,
397
+ though a saturated base that the cap stops early can land below.
398
+
399
+ Style Dictionary and Terrazzo keep `$extensions`, but their built-in color
400
+ transforms expect components from 0 to 1, so read the extension with a
401
+ transform of your own. [Native apps](#native-apps) maps each field.
402
+
403
+ ## Outside a theme
404
+
405
+ [`glaze.glow()`](./shadows-and-glows.mdx#outside-a-theme) takes `headroom` in
406
+ its `tuning` and returns `hdr` when its step passes white:
407
+
408
+ ```ts
409
+ const flare = glaze.glow({
410
+ bg: '#ffffff',
411
+ intensity: 12,
412
+ tuning: { headroom: 1 },
413
+ });
414
+
415
+ glaze.formatHdr(flare);
416
+ // → 'color(srgb 1.1813 1.1813 1.1813)'
417
+ ```
418
+
419
+ ## Where it shows
420
+
421
+ ### Browsers
422
+
423
+ No browser paints a CSS color brighter than white yet. The CSS Color 4
424
+ spaces, `srgb` included, are SDR by design: values past 1 are valid, but they
425
+ are fit into SDR when painted. A neutral HDR value paints white, the same as
426
+ the SDR value of a glow pinned at opaque white (`near-glow`). A tinted one
427
+ paints a lighter, less saturated tint, or white. And on a white surface,
428
+ where SDR is transparent, the HDR value covers what is drawn under it
429
+ ([Fills, not overlays](#fills-not-overlays)).
430
+
431
+ The planned way past white is the CSS Color HDR draft's `hdr-color()` (also
432
+ written `color-hdr()` there), which blends an SDR and an HDR color by the
433
+ display's headroom. No browser implements it and its name isn't settled.
434
+ `@media (dynamic-range: high)` reports what the device can do, not that HDR
435
+ is on: Low Power Mode and the brightness setting change that.
436
+
437
+ ### WebGPU
438
+
439
+ A WebGPU canvas can paint past white. Chrome and Edge 129+ show an
440
+ `rgba16float` canvas configured with `toneMapping: { mode: 'extended' }`
441
+ brighter than white on an HDR display: on Windows, macOS, ChromeOS, and
442
+ Android, and on Linux since 144 with Intel Gen12+ GPUs only (not in Android
443
+ WebView). Safari 26 accepts the option, but whether it paints past white is
444
+ unconfirmed, so check on the device. Firefox has WebGPU without extended tone
445
+ mapping, so its canvases stay SDR.
446
+
447
+ The preview below is a card on a tone-92 page, its edges glowing with
448
+ `card-glow`, an `intensity: 20` glow from the card. In the light scheme the
449
+ glow's target is tone 113, past white, so its SDR value is opaque white and
450
+ its HDR value is `color(srgb 1.1972 1.1972 1.1972)` (0.593 stops). CSS draws
451
+ the halo as a `box-shadow` in the SDR value; its 40px blur fades it from about
452
+ 58% at the card's edge. On an HDR display with HDR canvases, a WebGPU canvas
453
+ behind the card draws a matching halo in the HDR value instead, so the edge
454
+ glows brighter than the white card.
455
+
456
+ <Example {...haloTheme} visual="none" />
457
+
458
+ <Preview title="A card with an HDR glow (light scheme)" {...haloPreview} />
459
+
460
+ The canvas's default `colorSpace`, `'srgb'`, takes extended sRGB, so the
461
+ channels go in as Glaze writes them:
462
+
463
+ ```ts
464
+ import { hdrToSrgb } from '@tenphi/glaze';
465
+
466
+ const { hdr } = theme.resolve().get('card-glow')!.light;
467
+ const [r, g, b] = hdrToSrgb(hdr!); // 1.1972 per channel
468
+ ```
469
+
470
+ Check the mode after `configure()`: where a browser doesn't support HDR
471
+ canvases, `context.getConfiguration().toneMapping?.mode` isn't `'extended'`,
472
+ and Chrome 129 and 130 have no `getConfiguration()`. `'extended'` means only
473
+ that the browser supports HDR canvases, not that the screen shows them, so
474
+ the preview also checks `(dynamic-range: high)` before it replaces the CSS
475
+ halo. A page can't read the display's headroom, so keep `headroom` modest.
476
+
477
+ ### Native apps
478
+
479
+ Read the [DTCG export](#dtcg), which carries every field these take.
480
+
481
+ - **Apple** (iOS, iPadOS, macOS, tvOS, visionOS 26):
482
+ `UIColor(red:green:blue:alpha:exposure:)`, or its `NSColor` twin, with
483
+ `base.components` and `exposure`, reproduces Glaze's value. In SwiftUI,
484
+ apply `.exposureAdjust(exposure)` to a `Color(.sRGB, …)` made from `base`.
485
+ A view shows it brighter than white only once its layer opts in: set
486
+ `layer.preferredDynamicRange` to `.automatic`, `.constrainedHigh`, or
487
+ `.high`, since the default `.standard` tone-maps it into SDR. Where the
488
+ system limits headroom it tone-maps the color, which isn't Glaze's SDR
489
+ value (often translucent), so keep both tokens and switch yourself.
490
+ - **Android** (14 and later): set the window's color mode to
491
+ `COLOR_MODE_HDR`, and on Android 15 its `desiredHdrHeadroom` to the
492
+ brightest linear channel you draw. `Color.pack(r, g, b, 1f, space)` takes
493
+ `value.components` as they are, with
494
+ `ColorSpace.get(ColorSpace.Named.EXTENDED_SRGB)` as the space; in Compose,
495
+ `Color(r, g, b, 1f, ColorSpaces.ExtendedSrgb)`. Int colors, `<color>`
496
+ resources, and Compose text colors can't carry HDR.
497
+ - **Windows:** Direct2D, Win2D, and other FP16 scRGB surfaces take linear
498
+ light where 1 is 80 nits, not SDR white. Multiply the linear channels
499
+ (`hdrToLinearSrgb()`, or `value.components` decoded) by the display's SDR
500
+ white level in nits divided by 80, at run time.
501
+ - Flutter and React Native: no HDR color path in their own drawing has been
502
+ verified, so use the SDR value there, or draw the glow in a native view
503
+ with the Apple or Android APIs above.
504
+
505
+ ## Fills, not overlays
506
+
507
+ Where the HDR value clamps, as it does in CSS today, it paints an opaque
508
+ color: white, for a neutral glow. That matches SDR wherever SDR is already
509
+ opaque (`near-glow`). On a tone-100 surface it doesn't: SDR is transparent
510
+ there, and the clamped value covers anything drawn between the surface and the
511
+ glow. Use HDR glows for fills (a card's or button's own background), edge
512
+ highlights, and `box-shadow` colors, never for a state layer drawn over a
513
+ label.
514
+
515
+ ## Power and accessibility
516
+
517
+ - HDR is an enhancement. The design must work in SDR, and it does: the SDR
518
+ value is what every other display already shows.
519
+ - Don't animate or flash HDR values. A pulsing glow past white is a large
520
+ luminance swing, and HDR flash thresholds now appear in the WCAG 2.3.1
521
+ Understanding draft and ITU-R BT.1702-3.
522
+ - Brighter-than-white pixels cost power, and systems fall back to SDR to save
523
+ it (iOS Low Power Mode does). Keep headroom low and HDR areas small.