react-x11 2.51.0 → 2.52.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.
Files changed (91) hide show
  1. package/README.md +87 -54
  2. package/docs/README.md +410 -0
  3. package/docs/accessibility.md +628 -0
  4. package/docs/appearance.md +416 -0
  5. package/docs/architecture/accessibility.md +470 -0
  6. package/docs/architecture/animation.md +707 -0
  7. package/docs/architecture/container-queries.md +525 -0
  8. package/docs/architecture/custom-layout.md +334 -0
  9. package/docs/architecture/drag-and-drop.md +941 -0
  10. package/docs/architecture/element-layer-contents.md +622 -0
  11. package/docs/architecture/frame-pacing.md +255 -0
  12. package/docs/architecture/grid-layout.md +358 -0
  13. package/docs/architecture/protocol-efficiency.md +357 -0
  14. package/docs/architecture/video.md +1149 -0
  15. package/docs/architecture/windowed-regions.md +854 -0
  16. package/docs/click-to-component.md +125 -0
  17. package/docs/clipboard.md +281 -0
  18. package/docs/components.md +1749 -0
  19. package/docs/dbus.md +277 -0
  20. package/docs/debugging.md +464 -0
  21. package/docs/desktop-calendar.md +284 -0
  22. package/docs/desktop.md +648 -0
  23. package/docs/devtools.md +250 -0
  24. package/docs/drag-and-drop.md +663 -0
  25. package/docs/ecosystem/animation.md +394 -0
  26. package/docs/ecosystem/data-fetching.md +201 -0
  27. package/docs/ecosystem/dev-tooling.md +436 -0
  28. package/docs/ecosystem/forms.md +313 -0
  29. package/docs/ecosystem/headless.md +219 -0
  30. package/docs/ecosystem/i18n.md +89 -0
  31. package/docs/ecosystem/icons.md +410 -0
  32. package/docs/ecosystem/layout.md +216 -0
  33. package/docs/ecosystem/routing.md +146 -0
  34. package/docs/ecosystem/state.md +361 -0
  35. package/docs/ecosystem/testing.md +500 -0
  36. package/docs/ecosystem/theming.md +540 -0
  37. package/docs/ecosystem.md +473 -0
  38. package/docs/elements.md +2762 -0
  39. package/docs/embedding.md +288 -0
  40. package/docs/events.md +1025 -0
  41. package/docs/extending.md +2242 -0
  42. package/docs/eyedropper.md +223 -0
  43. package/docs/filedialog.md +258 -0
  44. package/docs/frame.md +580 -0
  45. package/docs/gl.md +170 -0
  46. package/docs/globalmenu.md +278 -0
  47. package/docs/glx.md +137 -0
  48. package/docs/macos.md +3295 -0
  49. package/docs/notifications.md +164 -0
  50. package/docs/packaging.md +611 -0
  51. package/docs/permissions.md +148 -0
  52. package/docs/react-features.md +430 -0
  53. package/docs/remote.md +223 -0
  54. package/docs/scale.md +339 -0
  55. package/docs/security.md +132 -0
  56. package/docs/styling.md +1928 -0
  57. package/docs/system.md +599 -0
  58. package/docs/testing.md +514 -0
  59. package/docs/typescript.md +117 -0
  60. package/docs/uri-schemes.md +380 -0
  61. package/docs/wayland-backend.md +591 -0
  62. package/docs/wayland.md +545 -0
  63. package/docs/windows-embedding.md +271 -0
  64. package/docs/windows-gl.md +221 -0
  65. package/docs/windows-integrations.md +233 -0
  66. package/docs/windows.md +1404 -0
  67. package/package.json +10 -3
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. package/src/wayland/xkb.js +137 -0
@@ -0,0 +1,1928 @@
1
+ # Styling
2
+
3
+ Style lives in one place: the `style` prop. A style property passed flat is
4
+ an error in development that names the fix, rather than the silent no-op an
5
+ unrecognised prop used to be.
6
+
7
+ ## The rule
8
+
9
+ One namespace per kind of thing, and no name in both:
10
+
11
+ - **`style`** — everything CSS has a concept for: layout, paint, text,
12
+ `cursor`, `overflow`, `zIndex`, `pointerEvents`, `container`.
13
+ - **props** — everything else: `title`, window geometry and size hints,
14
+ `resizable`, `appId`, `windowType`, `grab`, `value`, `src`, `focusable`,
15
+ `tabIndex`, `disabled`, handlers. The 3D elements of
16
+ `@react-x11/components/three`, under a reconciler of their own, keep flat
17
+ property props (`position`, `material`) — those are object properties, not
18
+ CSS.
19
+
20
+ `style` takes an object or a nested array, flattened left-to-right with
21
+ falsy entries skipped. That is what replaces the **selector** cascade:
22
+ precedence is written at the call site instead of resolved by specificity.
23
+
24
+ ```jsx
25
+ <box style={[s.card, isWide && s.wide, { backgroundColor: theme.panel }]} />
26
+ ```
27
+
28
+ Inheritance is a different thing and it does happen — see
29
+ [Inheritance](#inheritance-the-ink-the-face-and-the-size) below. The two are
30
+ easy to conflate because CSS ships them together: what is gone here is
31
+ _matching a rule against a tree_; what remains is a handful of properties
32
+ that travel down it, which is the part that makes a theme, a caption block
33
+ or a dimmed row expressible at all.
34
+
35
+ ## Lengths and shorthands
36
+
37
+ A length is a distance in **logical pixels**: `12` and `'12px'` are the same
38
+ value, and the display scale turns either into device pixels (see
39
+ [scale.md](scale.md)). Where the layout takes them, `'50%'` and `'auto'`
40
+ mean what they mean in CSS; a border's or an outline's width also takes
41
+ `'thin'`, `'medium'` and `'thick'` (1, 3 and 5).
42
+
43
+ ### Relative units
44
+
45
+ Every length also takes CSS's relative units, resolved for the node it is
46
+ written on into the same logical pixels:
47
+
48
+ | unit | one of it is | resolved again when |
49
+ | -------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
50
+ | `em` | the node's own font size — and on `fontSize` itself, the size it inherits, so `'1.5em'` is half again its parent's | an ancestor's font size changes |
51
+ | `rem` | the palette's `fontSize`: the size every label that names none is set in, 14 by default | the theme changes |
52
+ | `vw`, `vh`, `vmin`, `vmax` | a hundredth of the window the node is in — the `<window>` or `<popup>`, the size a `'@width >= 600'` block asks about | the window is resized |
53
+
54
+ A **percentage on `fontSize`** is of the inherited size, as `em` is. A
55
+ **percentage `borderRadius`** is of the box's shorter side: a corner here is
56
+ a circle, so `'50%'` is a circle on a square and a pill on anything longer,
57
+ where CSS would draw each corner as an ellipse. Everywhere else a percentage
58
+ is the layout's, as before.
59
+
60
+ `calc()`, `min()`, `max()` and `clamp()` over any of these resolve to one
61
+ number where the style is — `'calc(1rem + 2px)'`, and fluid type as
62
+ `fontSize: 'clamp(14px, 1rem + 0.5vw, 20px)'`. A **percentage takes part
63
+ only alone** (`'calc(100% / 3)'`): yoga takes one unit per value, and what a
64
+ percentage is of is decided inside the layout, so `'calc(100% - 16px)'` is an
65
+ error that says so. Pad a `'100%'` box rather than subtracting from it, or
66
+ give the room to `flexGrow`.
67
+
68
+ `lineHeight` takes all of CSS's forms and comes to the multiple the text
69
+ engines take: `1.5`, `'150%'` and `'1.5em'` are that multiple, and a length
70
+ — `'24px'`, `'1.75rem'` — is divided by the node's own font size, which is
71
+ what makes a design's "16 on 24" the line it asks for.
72
+
73
+ What a relative length costs is the re-resolution, and only on the nodes that
74
+ wrote one: a node records what its style read — the inherited size, the
75
+ palette's, the window's — and is resolved again by the walk that already
76
+ carries that change (the text cascade, the theme walk, the size-query
77
+ registry). A style written in pixels reads none of them. Inside an
78
+ `'auto'`-sized window a viewport unit makes the content depend on the window
79
+ that depends on the content; like a size query, it settles where the two
80
+ agree, so size such a window or keep the viewport units out of it.
81
+
82
+ Some of CSS's units are **not read**, and a value in one is an error naming
83
+ the unit and why — `invalid width "2ex" in <box style>: ex is not read — it
84
+ needs the font's x-height`:
85
+
86
+ - `ex` and `ch`, which need font metrics nothing measures yet (`0.5em` is the
87
+ usual stand-in);
88
+ - the container units, `cqw` and the rest: a container's size is known only
89
+ after a layout pass, and a length in it would lay the tree out again on
90
+ every frame the container moves — a percentage of the parent, or an
91
+ `'@container'` block, says the same without that;
92
+ - the print units, `pt`, `mm`, `in`: lengths here are logical pixels, which
93
+ the display scale turns into device ones.
94
+
95
+ So is a second value where a property takes one. Yoga's own setters used to
96
+ `parseFloat` whatever they were handed, so `'2ex'` was quietly two pixels.
97
+
98
+ ```jsx
99
+ <box
100
+ style={{
101
+ padding: '8px 16px',
102
+ margin: '0 auto',
103
+ border: '1px solid $border',
104
+ borderBottom: '2px solid $accent',
105
+ background: 'linear-gradient($surface, $background) $surface',
106
+ ':focus-visible': { outline: '2px solid $accent' },
107
+ }}
108
+ />
109
+ ```
110
+
111
+ CSS's shorthands are read the way CSS reads them, and expand into the
112
+ longhands before anything else sees the style:
113
+
114
+ | shorthand | sets |
115
+ | ------------------------------------------------- | ----------------------------------------------------------------------------------------- |
116
+ | `padding`, `margin` | the four sides, by CSS's one-to-four rule — `'8px 16px'`, `'0 auto'`, `'1 2 3'` |
117
+ | `inset` | `top`, `right`, `bottom`, `left`, the same way — `inset: 0` |
118
+ | `borderWidth`, `borderColor` | the four sides' widths or colours, the same way |
119
+ | `paddingInline`, `marginInline` | `…Start` and `…End`: one value for both, or two |
120
+ | `paddingBlock`, `marginBlock` | `…Top` and `…Bottom`, the same way |
121
+ | `gap` | `rowGap` and `columnGap` — `'4px 8px'` is the row gap first, as in CSS |
122
+ | `border` | `borderWidth`, `borderStyle`, `borderColor`: `'1px solid $border'` |
123
+ | `borderTop`/`Right`/`Bottom`/`Left`/`Start`/`End` | that side's width and colour: `borderBottom: '1px solid $border'` |
124
+ | `outline` | `outlineWidth` and `outlineColor`; `'none'` is `outlineWidth: 0` |
125
+ | `background` | `backgroundColor` and `backgroundImage` — a colour, a gradient, or both |
126
+ | `flex` | `flexGrow`, `flexShrink`, `flexBasis` — `1`, `'auto'`, `'none'`, `'1 1 0'`, `'0 0 240px'` |
127
+
128
+ A `$token` may stand for any one value inside them — `padding: '$gutter 0'`,
129
+ `border: '$hairline solid $border'` — and resolves before the value is read.
130
+
131
+ **The more specific property wins, wherever it is written.** A longhand
132
+ beats its shorthand, a side beats all four sides, and a pair beats all four
133
+ too: `paddingTop` beside `padding: '8px 16px'` is the top padding whether it
134
+ comes before or after it in the style. That is the rule `paddingLeft` beside
135
+ `padding: 8` has always followed, and it is not CSS's, which goes by order.
136
+ A flattened style array has no order left to go by — a longhand and its
137
+ shorthand end up as two keys of one object — and "the specific one wins" is
138
+ the rule you can read off a style without knowing how it was assembled.
139
+
140
+ A part a shorthand leaves out is left unset rather than reset to CSS's
141
+ initial value, since a longhand written beside it would win anyway. Three
142
+ things differ from CSS on purpose, and say so:
143
+
144
+ - **A line with no width is `medium`, 3px**, as in CSS; but a line with no
145
+ style is `'solid'` rather than CSS's `none`, because `borderWidth` alone
146
+ has always drawn here. `'none'` is a width of 0 — `border: 'none'`,
147
+ `outline: 'none'`.
148
+ - **A line with no colour is [`currentColor`](#currentcolor)**, the node's
149
+ ink, as in CSS — but the weakest thing in the style rather than whatever
150
+ came last: a colour the style names for that side anywhere else is the one
151
+ it takes, so `borderBottom: '1px solid'` beside `borderColor: '$border'`
152
+ is a `$border` line wherever each is written.
153
+ - **A box has one `borderStyle`.** `border`'s style is the box's, and a
154
+ side's only speaks when `border` named none; two shorthands naming
155
+ different ones are an error unless `borderStyle` says which.
156
+
157
+ What is not here says what to write instead: `font` (the longhands),
158
+ `paddingHorizontal` (React Native's name — `paddingInline`), a per-corner
159
+ radius (`borderRadius` rounds all four), `border-radius` (style names are
160
+ camelCase), and a misspelt name gets the nearest real ones. `background` and
161
+ `outline` are paint all the way down, so a state block may set them;
162
+ `border` sets a width, which is layout, so it may not. A `transition` keyed
163
+ by a shorthand reaches the longhands it expands into —
164
+ `transition: { border: 150 }` fades the `borderColor` a `border` set.
165
+
166
+ ## What it fixes on `<window>`
167
+
168
+ `width`/`height` on a `<window>` are the real X window's geometry — the user
169
+ can drag them, and `flush()` reads them back — so they must never reach the
170
+ root yoga node. That used to mean stripping them out of the style bag
171
+ (`_yogaProps`), and it forced the size hints to hide inside a `sizeHints`
172
+ object because `minWidth`/`maxWidth` were already taken by yoga.
173
+
174
+ With the split, both workarounds go:
175
+
176
+ ```jsx
177
+ <window
178
+ title="Editor"
179
+ width={900}
180
+ height={600}
181
+ x={40}
182
+ y={40} // the X window
183
+ minWidth={400}
184
+ minHeight={300}
185
+ resizable // WM hints, flat, no nesting
186
+ style={{ flexDirection: 'row', backgroundColor: '#1e1e1e' }} // the root box
187
+ />
188
+ ```
189
+
190
+ `WINDOW_HINT_PROPS` is mapped back to ntk's `sizeHints` shape in
191
+ `windowAttributes()`, and `style.backgroundColor` is forwarded as the window's
192
+ creation attribute — the ntk contract is unchanged, only the call site is.
193
+
194
+ ## Inline pseudo-states
195
+
196
+ The thing inline CSS cannot do, and the reason people keep a stylesheet:
197
+
198
+ ```jsx
199
+ <box
200
+ style={{
201
+ backgroundColor: theme.surface,
202
+ ':hover': { backgroundColor: theme.surfaceHover },
203
+ ':focus': { borderColor: theme.borderFocus },
204
+ }}
205
+ />
206
+ ```
207
+
208
+ `:hover`, `:focus-within`, `:focus`, `:focus-visible`, `:active`,
209
+ `:disabled`, `:drag-over`, `:dragging`.
210
+ These are **node states, not selectors** — each is something the node itself
211
+ already knows, so resolving them needs no specificity and no matching. The
212
+ event manager already tracks the hover path, the press chain and the focused
213
+ node; a state change recomputes one node's style and repaints. **No React
214
+ render.**
215
+
216
+ Precedence is fixed and low-to-high: `:hover` → `:focus-within` → `:focus` →
217
+ `:focus-visible` → `:active` → `:disabled` → `:drag-over` → `:dragging`,
218
+ merged per property, so a disabled control never looks hovered, a node that
219
+ is itself focused can say something narrower than one that merely contains
220
+ focus, and a drag in progress outranks all of the pointer and focus states.
221
+ Because the hover _path_ is the ancestor chain, hovering a child lights up an
222
+ ancestor's `:hover` block, exactly like CSS.
223
+
224
+ `:active` is a press, and it follows the same chain for the same reason: the
225
+ node under the pointer is whatever a control happens to be built out of — a
226
+ button's label, a switch's thumb — and it is the control that has to draw
227
+ the press. It is also **live for the whole gesture**: it drops when the
228
+ pointer leaves the chain and comes back when it returns, so `:active` always
229
+ means "releasing now activates this", matching the nearest-common-ancestor
230
+ rule the click itself is synthesized on.
231
+
232
+ This is not decoration. A control acts on the release, so `:active` is the
233
+ only thing it can show while it is held, and a click held half a second is
234
+ half a second of silence without it. Give every interactive thing one — see
235
+ [components.md](components.md#the-press-state).
236
+
237
+ ```jsx
238
+ <box
239
+ focusable
240
+ style={{
241
+ backgroundColor: theme.background,
242
+ ':hover': { backgroundColor: theme.surfaceHover },
243
+ ':active': { backgroundColor: theme.surfaceActive },
244
+ }}
245
+ />
246
+ ```
247
+
248
+ A `transition` on the same property is welcome — what matters is that the
249
+ change _starts_ on the press frame, not that it finishes there.
250
+
251
+ The keyboard has no press state: Space and Enter act on the down, so the
252
+ activation is immediate, but nothing draws held-ness. X reports auto-repeat
253
+ as release/press pairs and neither ntk nor node-x11 implements XKB's
254
+ `DetectableAutoRepeat`, so a held key cannot be told from fast tapping.
255
+
256
+ `:focus-visible` is focus that came from the keyboard — Tab, an arrow inside
257
+ a widget, `autoFocus`, `node.focus()`, a modal handing focus back as it
258
+ closes. A **press** sets `:focus` and not `:focus-visible`, for the reason
259
+ CSS grew the distinction: the user knows where they clicked, and a ring on
260
+ every click is noise, where a ring on Tab is the only cue a keyboard user
261
+ has. Put focus rings in `:focus-visible` and colour changes that are welcome
262
+ either way in `:focus`.
263
+
264
+ The exception is CSS's too: a press on a **text control** — `<textinput>`,
265
+ `<textarea>`, or a custom element reporting editable text — does set
266
+ `:focus-visible`. What the click told the user is where the caret went, not
267
+ that every keystroke from now on lands there, and that second fact is what
268
+ the ring is for.
269
+
270
+ `:focus-within` is focus on this node **or inside it** — CSS's, and the
271
+ answer to "the row should light up while the field in it is being typed
272
+ into", which is the one thing a node's own states cannot say. It is diffed
273
+ over the focused node's ancestor chain, the same walk `:hover` uses, and a
274
+ `<popup>` counts as inside the node it hangs off in the JSX tree, so a
275
+ `Select` with its menu open still reads as focused.
276
+
277
+ ```jsx
278
+ <box
279
+ style={{
280
+ borderColor: theme.border,
281
+ ':focus-within': { borderColor: theme.borderFocus },
282
+ }}
283
+ >
284
+ <textinput value={value} onChange={setValue} />
285
+ </box>
286
+ ```
287
+
288
+ The last two belong to drag and drop. `:drag-over` follows the pointer
289
+ during a drag on exactly the same ancestor-path rule as `:hover` — and,
290
+ like `:hover`, it says where the pointer is, not whether the node would
291
+ accept the drop; `useDropTarget`'s `isAccepted` is the one that answers
292
+ that. `:dragging` is set on the source node for the duration of a drag.
293
+ See [drag-and-drop.md](drag-and-drop.md).
294
+
295
+ **State blocks may only set paint properties** (`backgroundColor`,
296
+ `borderColor` and the per-side `borderTopColor`/…, `borderRadius`, `zIndex`,
297
+ `outlineWidth`, `outlineColor`,
298
+ `outlineOffset`, `opacity`, `color`) — enforced at declaration
299
+ time by `createStyles`. A `:hover` that could set `padding` would reflow the
300
+ tree on pointer move: jitter, and the end of the "hover is a repaint" property
301
+ that makes this worth having. Anything that changes layout or what renders
302
+ stays in React state.
303
+
304
+ ### The focus ring
305
+
306
+ **Every focusable node draws one already**, on `:focus-visible`, with no
307
+ styling at all — a bare `<box focusable>` included. It is not something an
308
+ application opts into, because a keyboard user cannot opt into needing it.
309
+ A text field gets it from a click as well as from Tab; everything else waits
310
+ for the keyboard (see `:focus-visible` above).
311
+
312
+ `outlineWidth`, `outlineColor` and `outlineOffset` override it, and they are
313
+ paint properties like any other: animatable, legal in a state block, and
314
+ painted **outside the border box** so switching one on cannot move the thing
315
+ it surrounds. That is the whole reason CSS has `outline` as well as `border`,
316
+ and the reason these are not in the layout vocabulary.
317
+
318
+ ```jsx
319
+ <box focusable /> {/* ring, for free */}
320
+ <box focusable style={{ outlineWidth: 0 }} /> {/* opted out */}
321
+ <box
322
+ focusable
323
+ style={{ ':focus-visible': { outlineWidth: 3, outlineColor: '#e17055' } }}
324
+ />
325
+ ```
326
+
327
+ A theme sets `focusRing`, `focusRingWidth` and `focusRingOffset` to restyle
328
+ every ring under it at once — the renderer reads them from the nearest
329
+ `theme` prop, so `<ThemeProvider>` covers the widgets and anything an
330
+ application writes itself.
331
+
332
+ ## Direction, and the logical edges
333
+
334
+ ```jsx
335
+ <box style={{ direction: 'rtl' }}>
336
+ <box style={{ flexDirection: 'row', paddingStart: 12, gap: 8 }}>…</box>
337
+ </box>
338
+ ```
339
+
340
+ `direction` is CSS's, `'ltr' | 'rtl' | 'inherit'`, and it inherits: setting
341
+ it mirrors that subtree and nothing above it. Rows run the other way, `flex-start`
342
+ is the right-hand end, and every logical edge below swaps sides with it.
343
+
344
+ **The default comes from the locale.** An app started under
345
+ `LANG=ar_EG.UTF-8` is mirrored with no configuration, because that is what
346
+ GTK and Qt both do and because the alternative is an Arabic desktop where
347
+ one application's panels are on the wrong side. Every locale that is not
348
+ written right to left answers `'ltr'`, so an app that never thinks about
349
+ this pays nothing. The palette carries it — `theme.direction` — so an app
350
+ with a language menu switches the whole UI with the `<ThemeProvider>` swap
351
+ it was already doing for colours:
352
+
353
+ ```jsx
354
+ <ThemeProvider value={{ direction: settings.rtl ? 'rtl' : 'ltr' }}>
355
+ ```
356
+
357
+ Prefer the provider over a bare `direction` style when the region contains
358
+ **widgets**. Yoga mirrors boxes on its own, so most of the widget set needs
359
+ nothing — a `<Checkbox>` is a row with a gap — but the decisions yoga cannot
360
+ make (which way an arrow key steps, which way a chevron points, which side a
361
+ submenu opens on) are read from the palette through `useDirection()`. A
362
+ provider plants the matching style property in the tree as it goes, so both
363
+ routes always agree under one.
364
+
365
+ ### The logical edges
366
+
367
+ | logical | physical in LTR | in RTL |
368
+ | -------------------------------- | ------------------------------ | ------- |
369
+ | `start` / `end` | `left` / `right` | swapped |
370
+ | `marginStart` / `marginEnd` | `marginLeft` / `marginRight` | swapped |
371
+ | `paddingStart` / `paddingEnd` | `paddingLeft` / `paddingRight` | swapped |
372
+ | `borderStartWidth` / `…EndWidth` | `borderLeftWidth` / `…Right…` | swapped |
373
+ | `borderStartColor` / `…EndColor` | `borderLeftColor` / `…Right…` | swapped |
374
+
375
+ **A logical edge wins over the physical one even in LTR**, the way CSS's
376
+ `padding-inline-start` wins over `padding-left`. Yoga's full order is
377
+ start/end, then the physical side, then `EDGE_HORIZONTAL`, then
378
+ `EDGE_ALL` — the opposite way round from what the vertical shorthands
379
+ suggest, which is why it is pinned in a test.
380
+
381
+ Write layout in the logical pair and it is the same layout in both
382
+ directions. Use the physical one when you mean the screen: a drop shadow, a
383
+ resize handle in a fixed corner.
384
+
385
+ ### What does not mirror
386
+
387
+ - **`<canvas>`** — the drawing is the application's, and `ctx` keeps its
388
+ top-left origin. An app that wants mirrored art reads `node.direction`
389
+ from the ref it already has.
390
+ - **A `<window>`'s or `<popup>`'s explicit `x`/`y`** — screen coordinates.
391
+ - **Vertical anything.** `top`/`bottom`, a column's order, a vertical
392
+ scrollbar's travel, Up/Down on every control.
393
+ - **The caret keys in a field.** Left and Right step through the _string_,
394
+ not across the screen, and so do Home and End — the editing model is
395
+ written in logical positions, and visual-order caret motion through bidi
396
+ text is its own question.
397
+
398
+ What **does**, besides the box tree: a vertical scrollbar moves to the left;
399
+ horizontal scrolling counts from the right-hand edge, so `scrollX: 0` still
400
+ means "at the beginning"; `textAlign: 'start'`/`'end'` resolve against the
401
+ box's direction rather than against the first strong character; a popup
402
+ prefers the start side, so a submenu opens leftwards and still flips at the
403
+ screen edge; and the inside of a `<textinput>`/`<textarea>` — see below.
404
+
405
+ ### Inside an editable field
406
+
407
+ A field is laid out at its box's direction, exactly as a `<text>` is, and the
408
+ direction does two separate things there:
409
+
410
+ - **It is the base level the value is shaped at.** UAX#9 resolves a run of
411
+ neutral characters — `"(1) 12:30"`, a filename, a lone bracket — against
412
+ the paragraph level, and "take it from the first strong character" is only
413
+ the rule for when nobody said. A field that never said reorders an Arabic
414
+ word typed into an English form as though the whole line were Arabic, in an
415
+ **LTR** window. So the field says.
416
+ - **It is the edge the text is against.** The value, the placeholder and the
417
+ caret start at the direction's start edge — the right-hand one under
418
+ `direction: 'rtl'` — and an unscrolled field still shows the _beginning_ of
419
+ its value, so a value too long for its box overflows towards the left.
420
+ Clicking, the caret, the selection bands and the scroll are read from one
421
+ placement, so they mirror together.
422
+
423
+ `textAlign` works on a field too: `'start'` (the default) is the base
424
+ direction's start edge, and `'center'`/`'end'` do what they say while the
425
+ value fits. A value that overflows its box is pinned to the start edge
426
+ whatever the alignment says, because that is what it is scrolled from.
427
+
428
+ The text's _own_ direction is not consulted: a Latin value in an RTL field is
429
+ placed at the field's right edge, the way `<input dir="rtl">` places one, and
430
+ there is no `dir="auto"` equivalent yet.
431
+
432
+ ## Per-side borders
433
+
434
+ ```jsx
435
+ <box
436
+ style={{ borderLeftWidth: 3, borderLeftColor: '$border', paddingLeft: 10 }}
437
+ >
438
+ {children} {/* a blockquote bar, no extra node */}
439
+ </box>
440
+ ```
441
+
442
+ A side width overrides `borderWidth` exactly the way `paddingLeft`
443
+ overrides `padding`, `borderBottom: '1px solid $border'` sets one side's
444
+ width and colour together, and a side colour
445
+ (`borderTopColor`/`Right`/`Bottom`/`Left`) falls back to `borderColor`.
446
+ `borderStartWidth`/`borderStartColor` and their `End` counterparts are the
447
+ [logical](#the-logical-edges) pair, and they win over the physical side. The
448
+ widths are layout — yoga sees each edge, so the bar above insets content on
449
+ the left only — and the colours are paint, legal in a state block like
450
+ `borderColor` itself.
451
+
452
+ This is what a rule or an accent edge should be built from: a blockquote's
453
+ bar, a table's row separators (`borderBottomWidth: 1` on each row), a tab's
454
+ underline. Composing the same line out of 1px `<box>`es works but pays a
455
+ node per row into layout and paint, and scatters what is really one style
456
+ value ("the table's border colour") across the tree.
457
+
458
+ Two edges of the v1 shape:
459
+
460
+ - **`borderRadius` requires uniform borders** — same width and colour on
461
+ all four sides. A non-uniform border paints square, ignores the radius,
462
+ and says so once in development; bars and rules are square, so this costs
463
+ nothing real.
464
+ - Corners between two painted sides of different colours are square and
465
+ deterministic: top and bottom span the box's full width, left and right
466
+ run between them. CSS mitres that corner diagonally; nothing built from
467
+ bars and rules can tell the difference.
468
+
469
+ ## Gradients and shadows
470
+
471
+ The two decorations a UI asks for after colour and radius, and the two that
472
+ used to mean giving up on `<box>` and drawing the panel by hand:
473
+
474
+ ```jsx
475
+ <box
476
+ style={{
477
+ padding: 16,
478
+ borderRadius: 10,
479
+ backgroundImage: 'linear-gradient(135deg, $accent, $accentActive)',
480
+ boxShadow: '0 2px 8px rgba(0, 0, 0, .4)',
481
+ ':hover': { boxShadow: '0 6px 20px rgba(0, 0, 0, .5)' },
482
+ }}
483
+ >
484
+ <text style={{ color: '$accentText' }}>Header</text>
485
+ </box>
486
+ ```
487
+
488
+ Both are **paint** properties: legal in a state block, and outside layout
489
+ entirely — a gradient fills the box the layout already decided on, and a
490
+ shadow is drawn beyond it and moves nothing. Both are written in CSS's
491
+ spelling, because both are values people already know by heart, and a
492
+ `$token` resolves **inside** them as well as as a whole value, which is what
493
+ keeps a themed decoration in a hoisted style.
494
+
495
+ ### `backgroundImage`
496
+
497
+ `linear-gradient(<direction>?, <stop>, <stop>, …)` or `'none'`, resolved
498
+ against the node's own box so it works wherever the node lands.
499
+
500
+ - The direction is an angle in degrees clockwise from "up" (`135deg`), a side
501
+ (`to right`), or a corner (`to bottom right`). Left out, it is `to bottom`.
502
+ - A stop is a colour, optionally followed by a `%` or a pixel position.
503
+ Positions left out are spread evenly; one that goes backwards is pulled up
504
+ to the one before it, so two stops at the same offset are a hard break.
505
+ - It paints **over** `backgroundColor`, which is CSS's order — a translucent
506
+ gradient tints the colour underneath it rather than replacing it.
507
+
508
+ Only linear gradients exist. A radial or conic one is a `<canvas onDraw>`
509
+ away (`createRadialGradient` draws on every backend but Windows', and
510
+ ntk's context has `createConicalGradient`),
511
+ and CSS's sizing keywords for them are most of the work for very little of
512
+ the demand.
513
+
514
+ One cost worth knowing, because it is not where anyone would look for it: a
515
+ gradient is a server-side source picture, so a **square** one is as cheap as
516
+ a colour — but a **rounded** one is not. ntk's rounded-rect fast path (cached
517
+ corner glyphs plus `FillRectangles`) only takes a solid colour, so a rounded
518
+ gradient falls back to a coverage mask rasterized on the client and uploaded
519
+ per fill: about 4 KB per box per repaint. Damage bounding means that is only
520
+ paid where something changed, but a list of rounded gradient rows is the one
521
+ shape to think twice about — see the `shapes: 24 gradient+shadow cards`
522
+ scenario in `npm run bench`, which prices it.
523
+
524
+ ### `boxShadow`
525
+
526
+ `<x> <y> [blur] [spread] [colour]`, comma-separated for several, painted
527
+ first-on-top like CSS's. The colour may be left out, which means the node's
528
+ own `color` — [`currentColor`](#currentcolor), which may be written out
529
+ too. A blurred shadow is a real gaussian — RENDER's convolution over a
530
+ coverage surface — cached by size, radius and blur, so a list of identical
531
+ cards renders one and composites it many times.
532
+
533
+ On macOS and Windows, which have no X server to bake one on, the shadow is
534
+ the 2d context's own `shadowBlur`, and the context draws a rounded rect's
535
+ shadow from a tile it makes once and stretches — see
536
+ [macos.md](macos.md#a-shadow-from-a-tile). The rest of this section is the
537
+ X11 route.
538
+
539
+ The blur is **baked into the cached surface's pixels**, in two separable 1-D
540
+ passes, rather than left as a filter on its picture. That is not an internal
541
+ detail: XRender applies a picture's filter on every composite, so a shadow
542
+ whose blur rode on the picture paid its whole kernel again every frame it was
543
+ drawn — a card-sized coverage surface at a wide blur is hundreds of millions
544
+ of multiply-accumulates, in the server, per shadow, per repaint. Baked, a
545
+ repaint composites a plain mask however wide the blur is, and the convolution
546
+ runs once per distinct geometry.
547
+
548
+ What it does not do, and why:
549
+
550
+ - **`inset` throws.** An inner shadow is a different drawing and a different
551
+ damage story, and painting an outer one where an inner was asked for is a
552
+ bug with no visible cause. An inset border or a `<canvas>` says it today.
553
+ - **Ignored on `<window>` and `<popup>`** (with a warning in development). A
554
+ shadow is painted outside the box, and a toplevel owns no pixels there —
555
+ a real one needs the window to carry a translucent margin of its own,
556
+ which is a feature rather than a line in the painter. Put the shadow on a
557
+ `<box>` inside the window; for a floating menu, the popup's own
558
+ `borderRadius` plus a border is what reads as raised today.
559
+ - **Neither transitions.** Both are several numbers and a colour in one
560
+ string, and `interpolate` moves one value; they snap. A card that wants to
561
+ rise on hover animates the `backgroundColor` or the border beside them.
562
+
563
+ A shadow is the first thing in this vocabulary that inks pixels the node
564
+ does not own, so it also widens what the node repaints — its offset, its
565
+ spread and the blur's tail — and the frame that _removes_ one claims where
566
+ it was. That is the renderer's business, not the application's, but it is
567
+ the reason a shadow is not free the way a colour is: prefer one shadow on
568
+ the card to one on every row inside it.
569
+
570
+ ## Opacity
571
+
572
+ `opacity` fades a node **and everything in it, as one**, the way CSS's does:
573
+
574
+ ```jsx
575
+ <box style={{ opacity: enabled ? 1 : 0.5, transition: { opacity: 150 } }}>
576
+ <svg viewBox="0 0 16 16">…</svg>
577
+ <text>Reminders</text>
578
+ </box>
579
+ ```
580
+
581
+ That is the difference from a colour's own alpha. `rgba(…, .5)` on a card's
582
+ background fades the background; it cannot fade the card, because every part
583
+ of it — the border, the icon, the caption — is drawn over the others, and at
584
+ half alpha each one shows what is under it: the card through the icon. With
585
+ `opacity` the subtree is drawn once, into an offscreen surface the size of
586
+ what it paints, and the surface is composited at the alpha.
587
+
588
+ It is a **paint** property: legal in a state block, and it transitions and
589
+ loops like the others — a halo that pulses, a section that fades in:
590
+
591
+ ```jsx
592
+ animation: { opacity: { from: 1, to: 0.5, duration: 800, alternate: true } }
593
+ ```
594
+
595
+ What it costs is that surface: one per faded node, the size of its paint
596
+ bounds inside the window, kept from one frame to the next while the node stays
597
+ faded and released when it is opaque again. `0` draws nothing and costs
598
+ nothing — and the node is still laid out and still hit, as in CSS. A value
599
+ outside 0 to 1 is clamped; one that is not a number throws in development.
600
+
601
+ On the Cocoa backend a faded box is drawn into the window's bitmap like this
602
+ rather than lifted onto a layer of its own (docs/macos.md): a box and its
603
+ content on separate layers would fade one over the other, which is the thing
604
+ `opacity` exists not to do.
605
+
606
+ ## Measuring text to its letters
607
+
608
+ ```jsx
609
+ <text style={{ fontSize: 24, textBoxTrim: 'cap-alphabetic', padding: 12 }}>
610
+ HEX
611
+ </text>
612
+ ```
613
+
614
+ `textBoxTrim` is CSS's `text-box-trim: trim-both` with `text-box-edge: cap
615
+ alphabetic`: the `<text>`'s box becomes the capitals down to the last
616
+ baseline, so padding around a label is measured from the letters and
617
+ centring centres what you can see. Default `'none'`.
618
+
619
+ The problem it solves is that **a line box is not the text**. It is the
620
+ font's ascent plus its descent plus its line gap, and the space over a
621
+ capital differs from the space under a baseline by `(ascent - capHeight) -
622
+ descent` — a property of the typeface, so a label is only ever optically
623
+ centred by luck. With the KaTeX face the tests use, a 24px label in a 12px
624
+ padded box sits 17px below the top and 18px above the bottom; trimmed, it is
625
+ 12 and 12.
626
+
627
+ **`lineHeight` is not an alternative.** It is a multiplier over the natural
628
+ line box and the leading still splits evenly above and below, so it moves
629
+ both edges by the same amount — it can change how much space there is, never
630
+ how it is balanced.
631
+
632
+ Two things worth knowing:
633
+
634
+ - It applies to `<text>`. `<textinput>` and `<textarea>` keep their full line
635
+ box, because their caret and selection geometry is measured against it.
636
+ - Trimming removes real space, so a control gets shorter: size the padding
637
+ for the result you want rather than to whatever the metrics happened to
638
+ add.
639
+
640
+ **The built-in widgets set it on every label**, which is why `paddingY` in a
641
+ palette is bigger than a CSS padding for the same look — it is the space you
642
+ see, not the space plus whatever the ascent left over. Which way a face is
643
+ off changes with the face: at 14px, SF NS leaves 3.7px above the capitals
644
+ against 2.9px below the baseline, so an untrimmed label rides low, while
645
+ Helvetica leaves 0.7 against 3.2 and it rides high. A widget cannot correct
646
+ for that, because it does not know the face it will be drawn in.
647
+
648
+ A glyph drawn as text — a check mark, a submenu arrow, an icon — is centred
649
+ on its own middle rather than sitting on a baseline, so trimming its box
650
+ moves it off centre. Those keep the full line box, and so should yours.
651
+
652
+ `<textinput>` cannot trim — its caret and selection are measured against the
653
+ full line box — so it reaches the same place from the other side: its **box**
654
+ is the cap band, its baseline goes where the space above the capitals equals
655
+ the space under it, and the glyphs are allowed to hang out of the box the way
656
+ a trimmed label's descenders do. The drawing is clipped one step out, at the
657
+ padding box, so an ascender and a descender are both there and neither can
658
+ reach the border. The caret and the selection follow, being drawn from the
659
+ same origin.
660
+
661
+ That is what makes a field the same height as the controls beside it. Padding
662
+ it with the palette's `paddingY` gives exactly the height a `<Button>` and a
663
+ `<Select>` have, because all three are now the same sum — the capitals, plus
664
+ that padding twice, plus the border:
665
+
666
+ ```jsx
667
+ <textinput
668
+ style={{
669
+ paddingTop: '$paddingY',
670
+ paddingBottom: '$paddingY',
671
+ paddingLeft: 10,
672
+ paddingRight: 10,
673
+ borderWidth: '$borderWidth',
674
+ }}
675
+ />
676
+ ```
677
+
678
+ `<textarea>` keeps its full line boxes, box and clip both: it is line spacing
679
+ that a paragraph is made of, and nothing hangs out of a stack of them.
680
+
681
+ ## Keeping text on one line, and saying when it did not fit
682
+
683
+ ```jsx
684
+ <text style={{ textWrap: 'nowrap' }}>{row.modified}</text>
685
+ ```
686
+
687
+ `textWrap: 'nowrap'` is CSS's, and it is what separates a cell from a
688
+ paragraph. A `<text>` measures height-for-width: hand it a narrow box and it
689
+ wraps to fit, which is right for prose and wrong for a row of a fixed height —
690
+ a date that wraps to two lines is not a taller row, it is a line and a half of
691
+ date with the rest sliced off, top and bottom, spilling over the rows either
692
+ side on the way. `'nowrap'` measures at unbounded width, so the overflow is
693
+ horizontal, which `overflow: 'hidden'` on the box around it already knows what
694
+ to do with. Default `'wrap'`.
695
+
696
+ A date is the case that wants exactly that: it is a fixed width, it always
697
+ fits, and clipping it would be a bug rather than a design. A **name** is not.
698
+ Clipped, a truncated file name and a short one look equally complete, and the
699
+ reader has no way to tell which they are looking at:
700
+
701
+ ```jsx
702
+ <text style={{ textWrap: 'nowrap', textOverflow: 'ellipsis' }}>
703
+ {file.name}
704
+ </text>
705
+ ```
706
+
707
+ `textOverflow` is CSS's `text-overflow`: `'clip'` (the default) slices
708
+ mid-glyph, `'ellipsis'` ends the line in a `…`. It is the careful version, not
709
+ a substring —
710
+
711
+ - the `…` is set in the font of the run it cut into, so an elided line ending
712
+ in a large or bold word gets a matching mark rather than one in the
713
+ paragraph's base style, and it falls back to `...` where neither that font
714
+ nor any fallback covers U+2026;
715
+ - the cut is at a grapheme boundary and the tail is **re-shaped**, because
716
+ kerning and ligatures across the cut change widths;
717
+ - it cuts the **visually** last run rather than the logically last one, so a
718
+ right-to-left line ends on its left.
719
+
720
+ `maxLines` is the other half — CSS has no single-property spelling for it
721
+ (`-webkit-line-clamp` is what everyone actually writes), so this is the name
722
+ the platforms that got a clean shot at it chose. A three-line blurb that ends
723
+ in a `…` is `{ maxLines: 3, textOverflow: 'ellipsis' }`; the lines past the
724
+ cap are dropped before the box is measured, so the height is the kept lines'
725
+ and not the whole paragraph's.
726
+
727
+ **`textOverflow: 'ellipsis'` on its own means one line.** Eliding happens off
728
+ a line count — the mark stands for the lines that were dropped — so an
729
+ ellipsis with no cap would have nothing to say and would silently do nothing.
730
+ One line is what a name, a path or a status line wants; `maxLines` is how to
731
+ ask for two or three.
732
+
733
+ ### What `nowrap` and `ellipsis` do together
734
+
735
+ They are not in tension, but they change what the `<text>` reports to layout,
736
+ which is worth knowing before a row moves under you.
737
+
738
+ A clipping `nowrap` label is measured at unbounded width, so it tells its
739
+ container it needs the **whole string** — and CSS's automatic minimum size
740
+ turns that into a floor. It cannot be squeezed, so a long name pushes the
741
+ column next to it out of the row and the overflow is dealt with somewhere
742
+ above.
743
+
744
+ An eliding one is measured against the width on offer, because at unbounded
745
+ width there is one line, one line is never over the cap, and nothing would
746
+ ever be cut. So its floor is small and it **gives way**: it takes the room
747
+ that is left, shows `Applicati…`, and the column beside it keeps its width.
748
+ That is the behaviour a table wants — a column that cannot show a file name
749
+ should say so rather than making every other column narrower to avoid it.
750
+
751
+ `<Table>` sets `nowrap` and `ellipsis` on every cell and header for that
752
+ reason.
753
+
754
+ What is truncated is a fact about the pixels and nothing else. A `<text>` that
755
+ was elided still reports the **whole** string as its accessible name, and the
756
+ caret and selection indices still index into the whole string — a screen
757
+ reader that read `Application Sup…` would be reading the layout instead of the
758
+ content.
759
+
760
+ ## Inheritance: the ink, the face and the size
761
+
762
+ Ten properties travel down the tree, and they are the ones CSS calls
763
+ inherited:
764
+
765
+ | property | |
766
+ | ----------------------- | ------------------------------------------ |
767
+ | `color` | the ink |
768
+ | `fontFamily` | the face |
769
+ | `fontSize` | the size |
770
+ | `fontWeight` | |
771
+ | `fontStyle` | |
772
+ | `fontVariationSettings` | a variable font's remaining axes |
773
+ | `textRendering` | how glyph origins are rounded at draw time |
774
+ | `letterSpacing` | the space after every character |
775
+ | `fontVariantNumeric` | which figures: tabular, oldstyle… |
776
+ | `fontFeatureSettings` | any other OpenType feature, by tag |
777
+
778
+ So a block of quiet type is a `<box>` and not a decision repeated at every
779
+ label inside it:
780
+
781
+ ```jsx
782
+ <box style={{ color: theme.textMuted, fontSize: 12 }}>
783
+ <text>Last modified</text>
784
+ <text>{row.modified}</text>
785
+ <Icon name="moreVertical" />
786
+ </box>
787
+ ```
788
+
789
+ A style property on the node itself still wins, the way a property always
790
+ wins over what a node inherits — and under the outermost element is the
791
+ palette, so text that names none of this is set in the theme's `text`,
792
+ `fontFamily` and `fontSize` ([theme tokens](#theme-tokens) below).
793
+
794
+ It reaches everything that draws with type, not just `<text>`: a
795
+ `<textinput>`, a `<canvas mono>` (which is what an `<Icon>` is), an `<svg>`
796
+ resolving `fill="currentColor"`, and any custom element
797
+ that asks `node.resolvedTextStyle()`
798
+ ([extending.md](extending.md#text-of-your-own)). A nested `<text>` span is
799
+ the same mechanism seen from closer up.
800
+
801
+ **A `:hover` block that sets `color` therefore reaches the labels inside.**
802
+ That is how CSS behaves and it is why there is no "group hover" here to
803
+ learn: `:hover` marks the row, `color` is inherited, and the row's label and
804
+ its icon follow.
805
+
806
+ ```jsx
807
+ <box style={{ color: theme.text, ':hover': { color: theme.accent } }}>
808
+ <text>Open recent</text>
809
+ <Icon name="chevronRight" />
810
+ </box>
811
+ ```
812
+
813
+ Nothing about that costs a layout pass. A state block may only set paint
814
+ properties and `color` (see above), so the only inherited property a pointer
815
+ can ever move is the ink — which drops the memoised text layouts under it
816
+ and repaints, with no re-measuring and no reflow. A `fontSize` change
817
+ _does_ re-measure, and it can only come from a React commit, a size query or
818
+ a theme.
819
+
820
+ What does **not** inherit: `textAlign`, `lineHeight`, `textWrap`,
821
+ `textOverflow`, `maxLines` and `textBoxTrim`. CSS inherits the first two;
822
+ here they are read by the node that owns the **box** the text flows in, and a
823
+ box is not something a descendant has. `<Icon>`'s `size` does not inherit either — a glyph is a
824
+ drawing rather than a letter, so it takes its default from the palette's
825
+ `fontSize` and stays put when a label around it shrinks.
826
+
827
+ **A value that is not one inherits.** `fontSize: '1.2ex'`, `fontStyle: 7`,
828
+ `fontWeight: 1200` or `lineHeight: 'tall'` is an error at the commit in
829
+ development, naming the property; where one gets past that — a `$token`, or
830
+ a production build — the text sets as though it had not been written, the way
831
+ CSS inherits past a declaration it cannot parse. Numbers may be numeric
832
+ strings (`fontSize: '20'`), as they always could, and a size is a
833
+ [length](#lengths-and-shorthands) like any other: `fontSize: '20px'` is 20,
834
+ and `'1.5em'` or `'150%'` is half again the size inherited. A `lineHeight`
835
+ in a length — `'24px'` — is that multiple of the node's own size. Before, a
836
+ size in px set no text at all and the others threw from inside a frame.
837
+
838
+ ### `currentColor`
839
+
840
+ The ink as a colour: `currentColor` is the node's own `color`, or the one it
841
+ inherits. It goes wherever a colour does — `backgroundColor`, `borderColor`
842
+ and each side of it, `outlineColor`, a `boxShadow`'s colour, a gradient stop,
843
+ and inside `border`, `outline` and `background` — and a line those
844
+ shorthands give no colour is drawn in it, as in CSS:
845
+
846
+ ```jsx
847
+ <box style={{ color: theme.textMuted, ':hover': { color: theme.accent } }}>
848
+ <box style={{ borderBottom: '2px solid' }}>
849
+ <text>Inbox</text>
850
+ </box>
851
+ </box>
852
+ ```
853
+
854
+ The label and its underline turn together, and nothing re-renders. It is
855
+ CSS's keyword in any case CSS takes it (`currentcolor` reads too), and on
856
+ `color` itself it is the colour inherited — CSS's `inherit`.
857
+
858
+ It stays the keyword in the node's style, which is CSS's computed value, and
859
+ becomes a colour where it is painted, so it follows the ink wherever the ink
860
+ moves: a `:hover` on a row above, a theme swap, a `color` transition frame by
861
+ frame. A node painting in it repaints when its ink moves, bounded to that
862
+ node, and a box does too although it draws no text; one that paints nothing
863
+ in it still costs nothing when the ink above it moves. A `<popup>` written
864
+ under the row inherits the ink like any other child and repaints with it.
865
+
866
+ Two places it cannot simply follow:
867
+
868
+ - **A transition runs between the colours at the swap.** `borderColor`
869
+ going from `'#d0d0d0'` to `'currentColor'` under a `transition` fades to
870
+ the ink as it is when the style changes, and lands on the keyword, which
871
+ follows the ink from then on. An ink moving under a `currentColor` that
872
+ stayed is not a change of the property and does not transition: the border
873
+ is the new ink at once. To fade it, transition `color`, and the border
874
+ rides along.
875
+ - **A loop cannot end in it.** A loop's ends are fixed for as long as it
876
+ runs, so `animation: { borderColor: { to: 'currentColor', … } }` is an
877
+ error saying so. Loop `color` instead, and write the border in
878
+ `currentColor`.
879
+
880
+ An element of your own that paints a colour from its style asks
881
+ `node.resolveColor(value)` for it
882
+ ([extending.md](extending.md#text-of-your-own)).
883
+
884
+ ## A font file of your own
885
+
886
+ An app that ships a face, or that shows one — a picker, a specimen, a
887
+ preferences page with a family name in it — asks for the file by path (or by
888
+ bytes) and gets back what to draw it with:
889
+
890
+ ```jsx
891
+ import { loadFont } from 'react-x11';
892
+
893
+ // `app` is the ntk connection — the one `createRoot({ app })` took, or
894
+ // `useApp()`'s inside a tree
895
+ const { font, family } = loadFont(app, '/path/to/Inter.ttf');
896
+
897
+ <text style={{ fontFamily: family, fontSize: 20 }}>Handgloves</text>;
898
+ ```
899
+
900
+ `family` comes **off the file** rather than out of the caller's head: it is
901
+ the font's own name, so an app that ships `Inter.ttf` goes on writing
902
+ `fontFamily: 'Inter'` in the styles it already has, and a registered face
903
+ beats fontconfig for that name — which is the point of shipping one. Several
904
+ faces of one family — regular, bold, italic — all keep the name and
905
+ `fontWeight` picks between them. Only a second file that would be
906
+ _unreachable_ under it, the same family at the same weight and slant, is
907
+ scoped: that one comes back as `Inter 2`. Draw with the name that comes back
908
+ and the question never arises.
909
+
910
+ `loadFont(app, path, { family: 'preview' })` names it yourself instead —
911
+ worth doing for a file the _user_ picked, to keep it out of the way of the
912
+ app's own type. `weight` and `style` override what the file claims about
913
+ itself, and `postscriptName` picks one face out of a `.ttc`.
914
+
915
+ From a component, `useFont` is the same thing with the connection already in
916
+ hand, and it returns null before anything is picked:
917
+
918
+ ```jsx
919
+ function Specimen({ path }) {
920
+ const picked = useFont(path); // path may be null
921
+ return <text style={{ fontFamily: picked?.family }}>Handgloves</text>;
922
+ }
923
+ ```
924
+
925
+ A file that will not parse throws, which an error boundary is the right home
926
+ for when the app ships the font and the wrong one when a user just chose it
927
+ in a dialog — reach for `loadFont` in that handler and catch there.
928
+
929
+ ### Reading a font without installing it
930
+
931
+ `openFont(app, source)` reads the file and changes nothing else:
932
+
933
+ ```js
934
+ const font = openFont(app, path);
935
+ font.metrics(30); // what the renderer lays out with
936
+ font.variationAxes; // { wght: { name, min, default, max } }
937
+ font.hasGlyph(0x20b8); // is the tenge sign in this face?
938
+ ```
939
+
940
+ The difference from `loadFont` is worth knowing before a font browser picks
941
+ one over the other. Registering a face does not only make a `fontFamily`
942
+ resolve to it: every registered font is consulted, ahead of the system
943
+ fonts, for **any codepoint the current face is missing**. That is exactly
944
+ what an app shipping a symbol or icon face wants, and exactly what an app
945
+ that is merely _previewing_ files does not — it would quietly change which
946
+ face draws the bullets and the curly quotes in its own UI.
947
+
948
+ Both verbs read a given file once per connection, however often they are
949
+ called: the face is cached in the app's font manager, so `useFont` in a
950
+ component that re-renders sixty times a second parses nothing, and
951
+ `loadFont` after `openFont` on the same path registers the face already
952
+ open. This matters more than it looks — a second copy of a font is a second
953
+ glyph cache and a second server-side glyphset, and a node built against one
954
+ copy cannot be painted by the other.
955
+
956
+ `font` is ntk's `Font`, which has more on it than the four members above
957
+ ([ntk's docs/fonts.md](https://github.com/sidorares/ntk/blob/master/docs/fonts.md)). If you need
958
+ anything else of ntk's — `Path2D`, `Image`, `Surface` — **import it from
959
+ `react-x11/ntk`, never from `ntk`**, and never declare `ntk` as a dependency
960
+ of your own. Two copies in one process are two font caches and two glyph
961
+ atlases, with the same consequence as above and a great deal further from
962
+ its cause.
963
+
964
+ ## `createStyles`
965
+
966
+ Identity is the point — a hoisted style object lets `applyProps` skip the
967
+ whole update with a `===` check, the same reason RN's `StyleSheet.create`
968
+ still exists now that its id registry is gone. It also validates keys, which
969
+ a bare object literal cannot: an unknown style property is an error at
970
+ declaration instead of a silent no-op.
971
+
972
+ ## `flattenStyle`
973
+
974
+ `flattenStyle(style)` collapses whatever the `style` prop accepts — an
975
+ object, an array, nested arrays, holes — into one plain object, which is what
976
+ the reconciler itself does before applying props. Two details are worth
977
+ knowing if you call it: a lone object is returned **as-is**, not copied, so
978
+ `===` still identifies a hoisted style; and a state block merges with one
979
+ already collected rather than replacing it, so
980
+ `[{ ':hover': { color } }, { ':hover': { backgroundColor } }]` keeps both.
981
+
982
+ ## In components
983
+
984
+ Every component takes `style` and merges it after its own, so an override
985
+ wins by position instead of clobbering a computed value:
986
+ `style: [control.style, base, checked && on, style]`.
987
+
988
+ `useControl(disabled, onActivate, { styled: true })` stops holding hover,
989
+ focus and the press in React state: no enter/leave handlers, no re-render on
990
+ pointer move. `Switch` and `Button` are the worked examples. Without it the
991
+ hook returns `hover`/`focused`/`pressed` as React state, which is what a
992
+ control needs when the part that has to change is not on the press chain —
993
+ a `Checkbox`'s well is a sibling of the label the press lands on, and no
994
+ node-local state block can cross that.
995
+
996
+ A component's own props are never style. `ProgressBar` takes `color`,
997
+ `Dialog` takes `width`/`height` — a dialog is a real popup window and needs
998
+ its geometry up front — and `ContextMenu` takes `fontSize` because it
999
+ measures labels with it. `Select` and `Slider` used to take `width` purely
1000
+ to put it in their own box; that is `style={{ width }}` now.
1001
+
1002
+ ## Theme tokens
1003
+
1004
+ A style value of `'$name'` resolves against the nearest theme above the node
1005
+ — `<ThemeProvider value={palette}>`, or a `theme` prop on any element. The
1006
+ sigil is what keeps it unambiguous: `'red'` is a CSS colour, `'$red'` is a
1007
+ token.
1008
+
1009
+ ```jsx
1010
+ const s = createStyles({
1011
+ card: { backgroundColor: '$panel', padding: '$gutter' },
1012
+ title: { color: '$text', fontSize: 20 },
1013
+ });
1014
+
1015
+ <window theme={palette} style={{ backgroundColor: '$bg' }}>
1016
+ <box style={s.card}>…</box>
1017
+ </window>;
1018
+ ```
1019
+
1020
+ That is the point of tokens: the style is **hoisted** — declared once,
1021
+ outside render, with no access to React context — and still follows the
1022
+ theme. Without them a palette has to be threaded to every element that
1023
+ paints (`style={[s.card, { backgroundColor: theme.panel }]}`).
1024
+
1025
+ Tokens are not colour-only; `padding: '$gutter'` resolves a number just as
1026
+ well. And they resolve wherever a style value is written: in a state block, in
1027
+ an `animation` declaration, and in a size, container or capability query block
1028
+ — `'@width >= 600': { padding: '$gutter' }` is the wide layout's gutter from
1029
+ the same palette.
1030
+
1031
+ Three of them are read with no `$` anywhere, because they are what text falls
1032
+ back to rather than something a style asked for: **`text`, `fontFamily` and
1033
+ `fontSize` are the ink, the face and the size of every `<text>` that names
1034
+ none of its own** ([components.md](components.md#theming)). They are the floor
1035
+ under [inheritance](#inheritance-the-ink-the-face-and-the-size) — what a node
1036
+ resolves to when no element above it named one either — which is what makes
1037
+ `<ThemeProvider value={{ fontFamily: 'Inter' }}>` a sentence an app says once.
1038
+ A style property still wins over it, the way a style property always wins over
1039
+ what a node inherits:
1040
+
1041
+ ```jsx
1042
+ <window theme={{ fontFamily: 'Inter', fontSize: 16 }}>
1043
+ <text>Inter at 16</text>
1044
+ <text style={{ fontFamily: '$monoFamily' }}>the palette's mono face, 16</text>
1045
+ <text style={{ fontSize: 24 }}>Inter at 24</text>
1046
+ </window>
1047
+ ```
1048
+
1049
+ **The floor is read from the window, not from the nearest provider.** That is
1050
+ the sentence to remember, because the failing shape looks right: a
1051
+ `<ThemeProvider value={palette}>` _inside_ a `<window>` feeds `useTheme()` and
1052
+ resolves every `$token` under it, so the styles that name a face get one — and
1053
+ every `<text>` that names none still falls back to the **window's** palette,
1054
+ which is the desktop's `sans-serif` unless the window itself was given a
1055
+ theme. An app that ships its own fonts then draws its headings in the face it
1056
+ shipped and its body copy in whatever the machine calls sans-serif, which is
1057
+ both wrong and slow: resolving a generic family costs an `fc-match` per weight
1058
+ on the first paint (~110ms each here).
1059
+
1060
+ Three ways to say it. For a whole app the first says it once, for the widgets
1061
+ and every window; the last is the one for a subtree:
1062
+
1063
+ ```jsx
1064
+ <ThemeProvider value={palette}> {/* above the windows: they take it */}
1065
+ <App />
1066
+ </ThemeProvider>
1067
+
1068
+ <window theme={palette}> {/* the floor, for everything inside */}
1069
+
1070
+ <box style={{ fontFamily: '$fontFamily', color: '$text' }}>
1071
+ {/* or hand the face to a subtree by inheritance, which needs no window */}
1072
+ </box>
1073
+ ```
1074
+
1075
+ `monoFamily` has no such fallback — nothing is monospace unless it says so, and
1076
+ that is what the `$` above is for. It is a token so that the code surfaces of
1077
+ an app, which are written by components that never meet, can be set from one
1078
+ place.
1079
+
1080
+ A `theme` prop anywhere scopes its subtree, and an inner one merges over the
1081
+ outer, so a panel can restate a colour or two without repeating a palette.
1082
+ Popups resolve through their place in the **tree**, not their window, so a
1083
+ menu inherits the theme of the UI that opened it even though it is a
1084
+ separate X window.
1085
+
1086
+ Note that a raw `theme` prop is exactly the object you wrote: it merges, but
1087
+ nothing is computed from it. The widget palette's derived tokens — the
1088
+ pressed `accentActive`/`surfaceActive`/`textMutedActive`/`dangerActive`, the
1089
+ `…Text` ink on each fill, and `surface` following `background` — are filled in
1090
+ by `<ThemeProvider>`, which plants the resolved palette in the tree for this
1091
+ lookup to find. Hand-writing `theme={{ accentHover: … }}` on a box gets the
1092
+ merge and not the derivation.
1093
+
1094
+ Changing the theme restyles the subtree in place. Nodes whose own props did
1095
+ not change are still updated — which is why a theme change also drops the
1096
+ memoised text layouts under it, or cached text would keep painting the old
1097
+ colour.
1098
+
1099
+ An unknown token is **reported and dropped**: the message names the token,
1100
+ the element, the component that wrote it, and every token the palette in
1101
+ force does have; that one property goes missing, so the widget paints
1102
+ visibly wrong rather than the app dying; and `process.exitCode` is set to 1,
1103
+ so a test run, a CI job or a supervisor still counts the run as failed.
1104
+
1105
+ That is deliberately not a throw. Resolution happens when a node's ancestry
1106
+ completes — inside a commit, and also from an X event when the desktop
1107
+ switches between light and dark — and on both of those paths a throw is
1108
+ uncatchable: it either lands on the `<window>` fiber above every error
1109
+ boundary the app wrote inside it, or it unwinds into the frame loop with no
1110
+ React on the stack at all. One wrong character in a style value should not
1111
+ be able to blank the screen.
1112
+
1113
+ Set **`REACT_X11_STRICT_TOKENS=1`** to make it fatal again — for a kiosk, or
1114
+ a build that would rather stop than paint something wrong. Strict mode also
1115
+ throws in the right _place_: the error is raised from the offending node's
1116
+ own `commitMount`, so an error boundary at any depth inside the window
1117
+ catches it and shows a fallback for that subtree. Where React genuinely is
1118
+ not on the stack — the desktop appearance switch — strict mode crashes,
1119
+ which is what it was asked for.
1120
+
1121
+ A token with **no** theme above it at all warns in dev — that one is
1122
+ otherwise silent, since the whole style is stripped rather than one value
1123
+ failing. Resolution is cached per (style object, theme object), so a hoisted
1124
+ style under one theme keeps its identity across renders and the `===` fast
1125
+ path still applies; a misspelling in a shared style is reported for every
1126
+ node that wears it, not just the first.
1127
+
1128
+ Resolution walks the node tree, not React context, so a palette has to reach
1129
+ the tree to be seen. `<ThemeProvider>` puts it in both places — the context
1130
+ widgets read with `useTheme()`, and a `theme` prop on a node — which is what
1131
+ makes provider and token one mechanism rather than two
1132
+ ([components.md](components.md#theming)). Widgets plant their own merged
1133
+ palette on their root node as well, so `$tokens` work inside a widget
1134
+ subtree — and in a style you pass one — with no provider anywhere.
1135
+
1136
+ With no `theme` prop above it at all, a token resolves against **the
1137
+ desktop's palette** — `backgroundColor: '$background'` in an app that never
1138
+ wrote a `<ThemeProvider>` is dark on a dark desktop, which is how that app
1139
+ blends in ([appearance.md](appearance.md)). A token no palette defines is
1140
+ reported the same way, naming every token the one in force does have.
1141
+
1142
+ ## Sticky positioning
1143
+
1144
+ `position: 'sticky'` is CSS's: the box is laid out in flow, exactly where
1145
+ `relative` would put it, and then held inside the nearest scroll pane by its
1146
+ insets. `top: 0` keeps a section's header at the top of the pane while the
1147
+ section scrolls under it — until the section's end arrives and carries the
1148
+ header off, which is how the next section's header takes over.
1149
+
1150
+ ```jsx
1151
+ const s = createStyles({
1152
+ pane: { overflow: 'scroll', flexGrow: 1 },
1153
+ section: { flexShrink: 0 },
1154
+ header: { position: 'sticky', top: 0, backgroundColor: '$background' },
1155
+ });
1156
+
1157
+ <box style={s.pane}>
1158
+ {groups.map((group) => (
1159
+ <box key={group.letter} style={s.section}>
1160
+ <text style={s.header}>{group.letter}</text>
1161
+ {group.people.map((person) => (
1162
+ <Row key={person.id} person={person} />
1163
+ ))}
1164
+ </box>
1165
+ ))}
1166
+ </box>;
1167
+ ```
1168
+
1169
+ - **The insets are thresholds, not offsets.** `top: 12` says the box's top
1170
+ edge may come no nearer than 12px to the pane's top edge; it does not move
1171
+ a box that is already further down. `top`, `bottom`, `left`, `right`,
1172
+ `start` and `end` all work, in any combination, and a percentage is of the
1173
+ pane's size. An edge with no inset does not stick.
1174
+ - **The pane is the nearest ancestor with `overflow: 'scroll'`** — a `<box>`
1175
+ or the `<window>`. Its edges are inside its border and over its padding:
1176
+ the band the content scrolls through. `overflow: 'hidden'` clips without
1177
+ scrolling, so — unlike on the web — a rounded card between a header and
1178
+ the pane does not quietly become the pane the header sticks to. With
1179
+ nothing above that scrolls, the box stays where layout put it.
1180
+ - **The parent is the limit.** The box's margin box never leaves its
1181
+ parent's content box, and that is the push. Wrap each header with the rows
1182
+ it heads, as above: a header that is a sibling of every row has the whole
1183
+ list for a parent and sticks for the whole list. A direct child of the
1184
+ pane has the pane's content for a parent, and sticks for the whole scroll.
1185
+ - **It paints over its siblings.** A sticky box paints after its siblings of
1186
+ the same `zIndex`, as CSS paints a positioned box over the ones in flow, so
1187
+ the rows that scroll under a header do not paint over it, and it takes the
1188
+ press where it covers them. `zIndex` still orders it first. Give it a
1189
+ background, or the rows show through.
1190
+ - **It lands in the frame that scrolled.** The pane applies the offset in
1191
+ the layout pass the scroll runs — never through a re-render — so a header
1192
+ cannot trail its content by a frame, and a pane of headers keeps the
1193
+ scroll blit: a header riding with its rows costs nothing, and a held one
1194
+ repaints its own rect.
1195
+ - **`onLayout` reports where layout put the box**, not where it is held,
1196
+ the same way a scroll does not fire it. Where it is held is
1197
+ `getClientRects()`.
1198
+
1199
+ Both axes at once, and one sticky box inside another, are what
1200
+ `examples/schedule.jsx` is made of: day banners pushed off by the next day, a
1201
+ row of room names held under the banner, a column of times held at the left,
1202
+ and the corner where the two meet.
1203
+
1204
+ Not yet: CSS's `scroll-padding`. Scrolling a node into view — keyboard focus
1205
+ reaching a row — brings it to the pane's edge, which can be under a held
1206
+ header.
1207
+
1208
+ Sticky is the built-in [custom position](#custom-positions): the same
1209
+ placement, written against the same context a registered one is handed.
1210
+
1211
+ ## Custom positions
1212
+
1213
+ A position is CSS's positioning scheme: `relative` lays the box out in flow
1214
+ and moves it by its insets, `sticky` lays it out in flow and holds it against
1215
+ a scroll pane's edges. `registerPosition` adds one of your own — the box laid
1216
+ out in flow, then moved by an offset your scheme works out once the layout
1217
+ pass is done, in the same frame:
1218
+
1219
+ ```jsx
1220
+ import { registerPosition } from 'react-x11/host';
1221
+
1222
+ // a layer that scrolls at a share of the pane's speed
1223
+ registerPosition('parallax', {
1224
+ options: { rate: { type: 'number', default: 0.5 } },
1225
+ place: (node, { pane, options }) =>
1226
+ pane ? { x: 0, y: Math.round(pane.scrollY * options.rate) } : null,
1227
+ });
1228
+
1229
+ <box style={{ overflow: 'scroll', flexGrow: 1 }}>
1230
+ <image src={hero} style={{ height: 240, position: 'parallax' }} />
1231
+ <box style={{ position: { name: 'parallax', rate: 0.2 } }}>…</box>
1232
+ </box>;
1233
+ ```
1234
+
1235
+ - **It is a `position` value**, by name or with its options, and switches
1236
+ like one — from a container query block too. The box is laid out exactly
1237
+ where `relative` would put it, and moved; nothing around it reflows.
1238
+ - **The insets are the scheme's.** Yoga never sees them as offsets: sticky
1239
+ reads them as thresholds, and a registered position reads them as whatever
1240
+ it documents.
1241
+ - **It runs on every layout pass**, the one a scroll runs included, so what
1242
+ it moves lands in the frame that scrolled; one that animates asks for the
1243
+ next frame itself. It paints and takes the press over its siblings of the
1244
+ same `zIndex`, like sticky.
1245
+ - **Options are typed**: a `length` is written in logical pixels like any
1246
+ length in a style, and a wrong option is reported, naming it, and takes
1247
+ its default.
1248
+
1249
+ The contract — what `place` is handed and may return — is in
1250
+ [extending.md](extending.md#a-position-of-your-own).
1251
+
1252
+ ## Grid
1253
+
1254
+ `display: 'grid'` lays a box's children out as a CSS grid, written the way
1255
+ CSS writes one — `gridTemplateColumns` on the box, `gridColumn` on a child —
1256
+ so a grid copied from a web codebase lays out here as it was written:
1257
+
1258
+ ```jsx
1259
+ // a form: the labels as wide as the widest, the fields taking the rest
1260
+ <box
1261
+ style={{
1262
+ display: 'grid',
1263
+ gridTemplateColumns: 'auto 1fr',
1264
+ columnGap: 12,
1265
+ rowGap: 6,
1266
+ }}
1267
+ >
1268
+ <text>Name</text>
1269
+ <textinput />
1270
+ <text>Postal address</text>
1271
+ <textarea />
1272
+ <box style={{ gridColumn: '1 / -1' }}>{/* a row the whole width */}</box>
1273
+ </box>
1274
+
1275
+ // a gallery: as many columns of 200 and up as fit, each row as tall as its
1276
+ // tallest card
1277
+ <box
1278
+ style={{
1279
+ display: 'grid',
1280
+ gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
1281
+ gap: 8,
1282
+ }}
1283
+ >
1284
+ {cards}
1285
+ </box>
1286
+
1287
+ // an app's shell, by named areas
1288
+ <box
1289
+ style={{
1290
+ display: 'grid',
1291
+ gridTemplateColumns: '200px 1fr',
1292
+ gridTemplateRows: 'auto 1fr auto',
1293
+ gridTemplateAreas: ['head head', 'side main', 'foot foot'],
1294
+ flexGrow: 1,
1295
+ }}
1296
+ >
1297
+ <Header style={{ gridArea: 'head' }} />
1298
+ <Sidebar style={{ gridArea: 'side' }} />
1299
+ <Main style={{ gridArea: 'main' }} />
1300
+ <Footer style={{ gridArea: 'foot' }} />
1301
+ </box>
1302
+ ```
1303
+
1304
+ | on the grid box | CSS | |
1305
+ | ----------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1306
+ | `gridTemplateColumns`, `gridTemplateRows` | `grid-template-*` | a track list — lengths in `px` (logical pixels), `%` and `fr`; `auto`, `min-content`, `max-content`; `minmax()`, `fit-content()`; `repeat()` with a count, `auto-fill` or `auto-fit` — or a number: that many equal tracks |
1307
+ | `gridTemplateAreas` | `grid-template-areas` | one quoted string per row — `'"head head" "side main"'` — or an array of the rows |
1308
+ | `gridAutoColumns`, `gridAutoRows` | `grid-auto-*` | the size of a track the grid adds for a child placed past its template; a number is a length |
1309
+ | `gridAutoFlow` | `grid-auto-flow` | `'row'` (the default) or `'column'`, and `'dense'` to back-fill a hole |
1310
+ | `gap`, `columnGap`, `rowGap` | `gap` | between the tracks |
1311
+ | `justifyItems`, `alignItems` | `justify-items`, `align-items` | where each child sits in its area: `'stretch'` (the default), `'flex-start'`, `'center'`, `'flex-end'` |
1312
+ | `justifyContent`, `alignContent` | `justify-content`, `align-content` | where the tracks sit when they leave room |
1313
+ | **on a child** | | |
1314
+ | `gridColumn`, `gridRow` | `grid-column`, `grid-row` | `2`, `'span 2'`, `'2 / 4'`, `'2 / span 3'`, `'1 / -1'` |
1315
+ | `gridArea` | `grid-area` | a name from `gridTemplateAreas`, or four lines; `gridColumn` and `gridRow` beside it win for their own axis |
1316
+ | `justifySelf`, `alignSelf` | `justify-self`, `align-self` | its own place in its area, over the grid's |
1317
+
1318
+ Things to know:
1319
+
1320
+ - **A number is a count** where CSS's value is a track list:
1321
+ `gridTemplateColumns: 3` is three equal columns, `repeat(3, minmax(0,
1322
+ 1fr))`, the way Tailwind's `grid-cols-3` means it — equal whatever is in
1323
+ them. `'repeat(3, 1fr)'` is CSS's own, a column never narrower than what is
1324
+ in it, so one long word widens its column. Inside a track list a length
1325
+ takes its unit, `'200px 1fr'`; on `gridAutoRows` a number is a length, and
1326
+ on `gridColumn` a line, counting from 1 at the start and from -1 at the
1327
+ end.
1328
+ - **`layout: 'grid'` is the same request**, under the name every layout goes
1329
+ by ([below](#custom-layouts)). A box whose `display` and `layout` disagree
1330
+ — `display: 'grid'` beside `layout: 'masonry'`, or `layout: 'grid'` beside
1331
+ `display: 'flex'` — is reported, and laid out as flexbox until they agree.
1332
+ - **The alignment keywords are this renderer's**, the ones `alignItems`
1333
+ already takes: `'flex-start'` and `'flex-end'` where CSS grid writes
1334
+ `start` and `end`. A value it does not take is an error listing the ones
1335
+ it does.
1336
+ - **The lines are whole pixels.** Tracks are sized exactly and their lines
1337
+ snapped, so neighbours in `'1fr 1fr 1fr'` meet on a shared pixel and every
1338
+ gap is as wide as it was written. Chrome lays a track out at a fraction of
1339
+ a pixel, so a row whose content fits in the fraction can wrap one line
1340
+ differently here.
1341
+ - **It is checked against Chrome.** A corpus of grids Chrome laid out is
1342
+ part of the test suite (`test/grid-conformance.test.js`) and lays out here
1343
+ within a pixel of Chrome's, but for the fraction above.
1344
+ - **Not here:** subgrid (a grid's child lays its own children out), baseline
1345
+ alignment, named lines (`[full-start]` — name an area instead) and the
1346
+ `grid` and `grid-template` shorthands. A value that uses one is an error
1347
+ naming it.
1348
+ - **It runs inside the layout pass**, as every layout does — on a window's
1349
+ resize, a live one included, and in the frame a container query flips. A
1350
+ query block can switch one property alone:
1351
+ `'@container width < 500': { gridTemplateColumns: 1 }`. In a scroll pane,
1352
+ the grid is a `<box>` inside the pane.
1353
+ - **What it costs** is its children's sizes, asked only where a track needs
1354
+ them. Fixed tracks, `minmax(0, 1fr)` and a bare count never ask; `1fr`'s
1355
+ minimum is the content floor, which is measured anyway; `auto`,
1356
+ `min-content` and `max-content` columns ask each child once for each width;
1357
+ `auto` rows ask each child its height at its column's width, which placing
1358
+ it needs anyway. What a child answered is remembered until something inside
1359
+ it changes, so a frame in which one card's text changed lays out that card.
1360
+
1361
+ ## Custom layouts
1362
+
1363
+ `layout` hands a box's children to a layout algorithm in place of flexbox.
1364
+ [Grid](#grid) is one, and the other two built in are:
1365
+
1366
+ ```jsx
1367
+ // a board of notes, each dropped into whichever column is shortest so far
1368
+ <box style={{ layout: { name: 'masonry', columnWidth: 220 }, gap: 12 }}>
1369
+ {notes.map((note) => (
1370
+ <Note
1371
+ key={note.id}
1372
+ note={note}
1373
+ style={note.pinned && { layoutItem: { span: 2 } }}
1374
+ />
1375
+ ))}
1376
+ </box>
1377
+
1378
+ // a dialog's buttons, every one as wide as the widest, at the row's end
1379
+ <box style={{ layout: 'equal-row', gap: 8, justifyContent: 'flex-end' }}>
1380
+ <Button>Cancel</Button>
1381
+ <Button>Save changes</Button>
1382
+ </box>
1383
+ ```
1384
+
1385
+ **`masonry`** is columns, each child placed in the one that is shortest so
1386
+ far: the layout of a photo wall or a board of notes of different heights,
1387
+ which flexbox cannot write — a wrapping row lines its items up in rows. As
1388
+ many `columnWidth`-wide columns as fit (240 by default), or exactly
1389
+ `columns`, sharing the width; `columnGap`, `rowGap` and `gap` space them,
1390
+ and a child's `layoutItem: { span: 2 }` lays it across two.
1391
+
1392
+ **`equal-row`** is a row whose children are all as wide as the widest of
1393
+ them. Flexbox makes siblings equal only by dividing a width it was given
1394
+ (`flex: 1`), and a row sized by its content — a dialog's buttons — has none
1395
+ to divide. Squeezed, the cells shrink together, down to the widest child's
1396
+ content floor and no further. `gap`, `justifyContent` and `alignItems` mean
1397
+ what they mean on a flex row, and `alignItems` defaults to `'stretch'`, so
1398
+ the cells are one height too.
1399
+
1400
+ Things to know:
1401
+
1402
+ - **It runs inside the layout pass.** The box's size is what the algorithm
1403
+ answers and its children are where it puts them, in the same frame — on
1404
+ a window resize, a live resize, a scroll or a container query flip, with
1405
+ no React render involved. That is the difference from measuring in
1406
+ `onLayout` and setting state, which paints the old arrangement first
1407
+ ([react-features.md](react-features.md#measuring-a-node)).
1408
+ - **It is a style property**, so a container query block can switch it:
1409
+ `'@container width < 500': { layout: { name: 'masonry', columns: 1 } }`.
1410
+ - **Right to left is mirrored for you.** An algorithm places from the left,
1411
+ and the renderer mirrors each child across the box when it reads right to
1412
+ left: a masonry fills from the right-hand column.
1413
+ - **Absolutely positioned children** are not the algorithm's: they are
1414
+ placed by their insets against the box's padding box, as anywhere else.
1415
+ - **A child's content floor still holds.** A row inside a masonry card
1416
+ squeezes its label only as far as its longest word, the rule everywhere
1417
+ else ([elements.md](elements.md#everything-shrinks-nothing-shrinks-to-nothing)).
1418
+ - **Not on a scroll pane or a window.** A pane's size is its viewport's,
1419
+ not its content's: put the layout on a `<box>` inside it, which is what it
1420
+ scrolls. It is reported once, and the pane lays out as it would without.
1421
+ - **A layout that throws**, or a name nobody registered, is reported, and
1422
+ the box is laid out as flexbox — in the same frame.
1423
+
1424
+ Writing one of your own is `registerLayout` from `react-x11/host`
1425
+ ([extending.md](extending.md#a-layout-algorithm-of-your-own)). The design
1426
+ record, with why it has to run in the pass rather than in an effect, is
1427
+ [architecture/custom-layout.md](architecture/custom-layout.md).
1428
+
1429
+ ## Transitions
1430
+
1431
+ `transition` names how long a change takes. A number covers every animatable
1432
+ property; an object picks them individually.
1433
+
1434
+ ```jsx
1435
+ <box
1436
+ style={{
1437
+ backgroundColor: theme.surface,
1438
+ transition: 120, // or { backgroundColor: 120, left: 200 }
1439
+ ':hover': { backgroundColor: theme.surfaceHover },
1440
+ }}
1441
+ />
1442
+ ```
1443
+
1444
+ Numbers lerp and colours lerp per channel through ntk's own CSS colour
1445
+ parser, so anything the paint path accepts animates. Two percentages of the
1446
+ same unit lerp as numbers — `'-40%'` to `'100%'` is a value travelling across
1447
+ whatever contains it, with nothing measuring the container. A value with no
1448
+ meaningful midpoint — an enum like `flexDirection`, `auto`, a percentage
1449
+ against a pixel value — snaps instead, and `zIndex` is excluded on purpose:
1450
+ restacking every frame is not an animation. A duration keyed by a
1451
+ [shorthand](#lengths-and-shorthands) is its longhands', the longhand's own
1452
+ entry winning: `{ border: 150 }` fades the colour `border: '1px solid …'`
1453
+ set, and `{ padding: 200 }` moves each side `padding: '8px 16px'` expanded
1454
+ into.
1455
+
1456
+ The easing is a fixed ease-out cubic. A transition starts from **what is on
1457
+ screen**, not from the declared value, so interrupting one reverses from
1458
+ where it got to rather than jumping to the end first. One to or from
1459
+ [`currentColor`](#currentcolor) runs between the colours as they are at the
1460
+ change.
1461
+
1462
+ The animation _is_ the repaint loop: the window keeps asking its frame clock
1463
+ for frames while any transition is unfinished, and stops the frame the last
1464
+ one lands. Nothing polls, and there is no per-widget
1465
+ `requestAnimationFrame`.
1466
+
1467
+ Transitions may animate layout properties, unlike state blocks. That is not
1468
+ an inconsistency: a pointer move must never reflow the tree, but an author
1469
+ who writes `transition: { left: 200 }` has asked for animated layout and
1470
+ pays a layout pass per frame for it, and a repaint of whatever that pass
1471
+ moves rather than of the window. `Switch` is the worked example — the
1472
+ thumb is absolutely positioned and slides on `start`, because
1473
+ `justifyContent` would flip between the ends with nothing in between.
1474
+
1475
+ ### Loops
1476
+
1477
+ A transition is over when it arrives. `animation` is the other shape: a
1478
+ property that travels between two values and **keeps doing it**, for the
1479
+ spinner, the pulse, and everything else that means "working, no idea how
1480
+ long".
1481
+
1482
+ ```jsx
1483
+ <box
1484
+ style={{
1485
+ position: 'absolute',
1486
+ start: '-40%',
1487
+ width: '40%',
1488
+ animation: { start: { to: '100%', duration: 1100 } },
1489
+ }}
1490
+ />
1491
+ ```
1492
+
1493
+ Per property, and each one names where it goes and how long a crossing
1494
+ takes:
1495
+
1496
+ | key | |
1497
+ | ----------- | ---------------------------------------------------------------- |
1498
+ | `to` | the far end |
1499
+ | `from` | the near end; **defaults to what the style declares** |
1500
+ | `duration` | one crossing, in ms |
1501
+ | `easing` | `'linear'` (default), `'ease-in'`, `'ease-out'`, `'ease-in-out'` |
1502
+ | `alternate` | turn around at each end instead of wrapping back to `from` |
1503
+ | `delay` | when the loop's own time starts, in ms; negative starts it early |
1504
+
1505
+ `from` defaulting to the declared value is what makes a pulse read as a
1506
+ resting colour plus somewhere to go:
1507
+
1508
+ ```jsx
1509
+ style={{
1510
+ backgroundColor: theme.track,
1511
+ animation: {
1512
+ backgroundColor: { to: theme.accent, duration: 900, alternate: true },
1513
+ },
1514
+ }}
1515
+ ```
1516
+
1517
+ That value is also **where the property rests whenever the loop is not
1518
+ running** — before the first frame, off screen, or under reduced motion — so
1519
+ a loop never leaves a node with no value for the thing it animates.
1520
+
1521
+ Every loop starts when its node gets the style, so loops with one duration
1522
+ move together for good. `delay` is how they stop doing that — CSS's
1523
+ `animation-delay`. A positive delay holds `from` that long before the first
1524
+ crossing, once rather than every cycle; a negative one starts the loop that
1525
+ far in, already moving, which is the way to say a phase:
1526
+
1527
+ ```jsx
1528
+ const TypingDot = ({ index }) => (
1529
+ <box
1530
+ style={{
1531
+ width: 6,
1532
+ height: 6,
1533
+ borderRadius: 3,
1534
+ backgroundColor: theme.textMuted,
1535
+ opacity: 0.3,
1536
+ animation: {
1537
+ opacity: { to: 1, duration: 500, alternate: true, delay: -index * 160 },
1538
+ },
1539
+ }}
1540
+ />
1541
+ );
1542
+ ```
1543
+
1544
+ A changed `delay` is a different loop and starts from the top, as a changed
1545
+ `duration` does.
1546
+
1547
+ Why this and not a repeating `transition`: a transition is defined by a
1548
+ _change_, from what is on screen to a new target, and it has no cycle to
1549
+ repeat. A loop has no target — it has two ends and a period. They are
1550
+ different declarations because they are different things, and one of them
1551
+ would otherwise have to be spelled as the other with a flag.
1552
+
1553
+ The easing default differs from a transition's for the same reason. A change
1554
+ that ends looks right slowing into its new value; a cycle that restarts
1555
+ would stutter at the wrap, so a loop is `linear` unless you say otherwise.
1556
+
1557
+ **A loop costs a repaint of what moves.** It is the same machinery
1558
+ transitions run on — the window's own frame clock, a damage region claimed
1559
+ per frame — so an indeterminate progress bar repaints the bar, not the
1560
+ window. The alternative an app would otherwise write, `setInterval` →
1561
+ `setState`, is a re-render of the component and a repaint of whatever the
1562
+ damage heuristics decide, at a cadence unrelated to when the window can
1563
+ present.
1564
+
1565
+ On macOS a loop — or a transition — on a plain box's colour, border width
1566
+ or radius is handed to Core Animation instead and costs no frames at all:
1567
+ the layer presenter runs it on the node's own layer, and the surface
1568
+ presenter promotes the node to a layer of its own above the window for as
1569
+ long as it animates, where nothing is painted over it
1570
+ ([macos.md](macos.md#layer-promotion-the-animated-few-on-their-own-layers-above-the-surface-presenter)).
1571
+ So is a loop on an inset of a box out of the flow that says no pointer
1572
+ lands on it — `position: 'absolute'` and `pointerEvents: 'none'`, as the
1573
+ block of `<ProgressBar indeterminate>` does — since it moves that box and
1574
+ nothing else: it becomes the layer's position. Without `pointerEvents:
1575
+ 'none'` it stays on the clock, because input is hit where the layout put
1576
+ the box.
1577
+ Anything else runs as above. `examples/animation.jsx` has both shapes side
1578
+ by side, with a frame counter in the terminal that shows the difference.
1579
+
1580
+ **And it stops itself**, which is the whole reason this is core's job. A
1581
+ forever-loop keeping a frame clock alive is invisible when it is wrong, so
1582
+ every way of going off the screen is wired to it:
1583
+
1584
+ | | |
1585
+ | -------------------------------------------------------------------------------------------------------------------------- | --- |
1586
+ | the window is unmapped, minimized, or fully obscured under a bare WM | |
1587
+ | anything above the node hides it — `display: 'none'`, `<Suspense>`, `<Activity>` | |
1588
+ | the node unmounts, or the style stops declaring the loop | |
1589
+ | the desktop asked for **reduced motion** ([system.md](system.md#usedesktopsettings--how-the-desktop-wants-an-app-to-feel)) | |
1590
+ | all the loop can draw is out of sight — scrolled out of its pane, clipped away by a box above it, off the window | |
1591
+
1592
+ Each one leaves the frame clock idle, and every one of them runs the loop
1593
+ again when it goes away — with its phase reset, since a loop that resumes
1594
+ mid-cycle would have been drawing where nobody could see it.
1595
+
1596
+ Out of sight is judged after each layout pass, by what **every** frame of
1597
+ the loop can reach and never by where the current one has the node: the
1598
+ block of `<ProgressBar indeterminate>` starts each crossing off its track,
1599
+ and is judged by the track it slides in. A loop on a colour, an opacity or
1600
+ a radius reaches what the node and its children cover. One on the outline,
1601
+ or one that moves or sizes a node out of the flow (`position: 'absolute'`),
1602
+ reaches the nearest box that clips it, or the whole window when nothing
1603
+ does. And one that moves or sizes a node in the flow is never out of sight
1604
+ at all: each of its frames can move the node's siblings and resize the
1605
+ boxes above it, so it runs for as long as its window is up.
1606
+
1607
+ Reduced motion is honoured in core, once, for every loop in every
1608
+ application: `Gtk/EnableAnimations` off means loops do not start. What core
1609
+ cannot decide is what the _still_ frame should look like, which is the
1610
+ author's — `<ProgressBar indeterminate>` parks its block inside the track
1611
+ rather than at its off-screen starting point, so a bar that cannot move
1612
+ still shows something in progress.
1613
+
1614
+ Nothing about this is a per-frame callback: `animation` describes the motion
1615
+ and the renderer runs it. For a `<glarea>` drawing its own frames,
1616
+ [`frameLoop`](gl.md) is the per-frame seam, and a 2D one is not
1617
+ built yet.
1618
+
1619
+ ## Window size queries
1620
+
1621
+ `'@width >= 600'` and friends are the X11 analogue of `@media`: what a style
1622
+ can usefully ask about here is the window it is laid out in, not the screen.
1623
+
1624
+ ```jsx
1625
+ const s = createStyles({
1626
+ bar: {
1627
+ flexDirection: 'column',
1628
+ gap: 4,
1629
+ '@width >= 600': { flexDirection: 'row', gap: 16 },
1630
+ },
1631
+ });
1632
+ ```
1633
+
1634
+ `width` and `height`, with `>=`, `<=`, `>` or `<`. Blocks that match are
1635
+ merged in declaration order, before state blocks — so a `:hover` inside the
1636
+ wide layout still wins over the wide layout.
1637
+
1638
+ **A size query may set layout properties, unlike a state block.** That is
1639
+ not an inconsistency: a pointer state must never reflow the tree, but a size
1640
+ query is only ever re-evaluated inside a layout pass that a resize has
1641
+ already required, so it costs nothing extra. Nodes that declare one are
1642
+ registered with their window and re-resolved just before it lays out, and
1643
+ only when the size actually changed.
1644
+
1645
+ A malformed query is an error rather than a key that silently never
1646
+ matches.
1647
+
1648
+ ## Container queries
1649
+
1650
+ `'@container width >= 400'` asks about the box a node is inside rather than
1651
+ the window it is in — CSS's `@container`, for the case a window query cannot
1652
+ tell apart: the same card in a 220px sidebar and in a 600px pane, in one
1653
+ 900px window.
1654
+
1655
+ ```jsx
1656
+ const s = createStyles({
1657
+ pane: { container: true, flexGrow: 1, minWidth: 0, overflow: 'scroll' },
1658
+ card: {
1659
+ flexDirection: 'column',
1660
+ gap: 8,
1661
+ '@container width >= 400': { flexDirection: 'row', gap: 16 },
1662
+ },
1663
+ });
1664
+
1665
+ <box style={s.pane}>
1666
+ <box style={s.card}>…</box>
1667
+ </box>;
1668
+ ```
1669
+
1670
+ A container is declared with the **`container` style property** — CSS's
1671
+ `container-type` and `container-name` in one — and a block answers to the
1672
+ **nearest ancestor that declares one**. Wrapping the card in another `<box>`
1673
+ for a border changes nothing: the parent is not the container, the declared
1674
+ ancestor is, which is what lets a component be put anywhere and still get
1675
+ the answer its author meant. The declaration changes nothing about the
1676
+ container itself; it is neither layout nor paint, only what the blocks under
1677
+ it resolve against. Being a style property, it can be set from inside a
1678
+ query block — a box that is a container only in the wide layout — and
1679
+ `false` takes a declaration back from earlier in a style array.
1680
+
1681
+ `width` and `height`, with `>=`, `<=`, `>` or `<`, in the logical pixels the
1682
+ dependent's own lengths are written in. Blocks that match are merged in
1683
+ declaration order with the other query blocks, before the state blocks.
1684
+
1685
+ ### Named containers
1686
+
1687
+ `container: 'pane'` names the container, and `'@container pane width >= 300'`
1688
+ asks for it by name — **reaching past** any nearer container. A card that is
1689
+ a container for its own contents does not capture a query a label inside it
1690
+ meant for the pane:
1691
+
1692
+ ```jsx
1693
+ const s = createStyles({
1694
+ pane: { container: 'pane', flexGrow: 1, minWidth: 0, overflow: 'scroll' },
1695
+ card: {
1696
+ container: true, // the card's contents ask about the card…
1697
+ '@container width >= 380': { flexDirection: 'row' }, // …and the card asks the pane
1698
+ },
1699
+ detail: { '@container width < 220': { display: 'none' } }, // the card
1700
+ label: {
1701
+ display: 'none',
1702
+ '@container pane width >= 300': { display: 'flex' }, // the pane, past the card
1703
+ },
1704
+ });
1705
+ ```
1706
+
1707
+ `examples/container-queries.jsx` is that design in full: one `<Card>` in a
1708
+ sidebar and in a main pane, both panes named `pane`, so the labels come and
1709
+ go with whichever pane a card is in.
1710
+
1711
+ A name nothing above carries makes the block **not apply, quietly** — that
1712
+ is what lets one component render inside and outside a `sidebar`. An
1713
+ **unnamed** query with no container above it is reported in development,
1714
+ the way an unknown token is: the window already has a spelling (`'@width'`),
1715
+ so the only thing a silent fallback to it could hide is a forgotten
1716
+ declaration. `REACT_X11_STRICT_TOKENS=1` makes the report a throw.
1717
+
1718
+ ### When it is answered, and what it costs
1719
+
1720
+ Like a size query, a container block **may set layout properties**, and for
1721
+ the same reason: it is only ever re-evaluated inside a layout pass. Unlike
1722
+ one, it cannot be answered _before_ the pass — a container's size is what
1723
+ the pass produces — so the blocks are resolved after it, and the tree is laid
1724
+ out once more when an answer moved. A frame where no answer crosses a
1725
+ threshold (a scroll, a hover, a resize between two breakpoints) pays one
1726
+ size read per container and nothing else. A frame where one does pays what
1727
+ the same change would have cost had React made it.
1728
+
1729
+ When what depends on the size is not a style — how many rows to build, which
1730
+ component to render — the same size reaches React through `onLayout` on any
1731
+ element ([react-features.md](react-features.md#measuring-a-node)). It
1732
+ arrives after the pass, so the reaction lands a frame later than a block
1733
+ would; that is the reason to write a style as a block where one can.
1734
+
1735
+ ### A block must not move the size it asks about
1736
+
1737
+ CSS makes this impossible by construction: `container-type: inline-size` is
1738
+ size containment, the container is sized as if it had no content, and
1739
+ nothing a descendant does can move it. Yoga has no containment, so here it is
1740
+ a rule, and breaking it is detected rather than prevented. A block that
1741
+ grows the container past the very threshold that turned it on — and shrinks
1742
+ it back when it turns off — has no size that satisfies it. The renderer
1743
+ notices (the node comes back to an answer it already held this frame),
1744
+ **holds the answer it has**, and says so once in development, naming the
1745
+ node. The answer stays held until the container's size moves for some other
1746
+ reason.
1747
+
1748
+ The way to stay clear of it is also what makes a good container: a size that
1749
+ does not come from the content below it.
1750
+
1751
+ | | |
1752
+ | ------------------------------------------------ | ----------------------------------------------------------------- |
1753
+ | an explicit `width` / `height` | fixed, whatever is inside |
1754
+ | a flex item with `minWidth: 0` and a `flexBasis` | the parent distributes it; `flex: 1` is both in one |
1755
+ | `overflow: 'scroll'` | **the natural container**: content overflows rather than grows it |
1756
+
1757
+ The common responsive pattern — less content when narrow, more when wide —
1758
+ is monotone and settles on the first re-layout; a warning means the design
1759
+ genuinely contradicts itself.
1760
+
1761
+ ## Capability queries
1762
+
1763
+ `'@supports transparency'` is to `@supports` what the size queries are to
1764
+ `@media`: what a style can ask about the **server**, rather than about the
1765
+ window it is in.
1766
+
1767
+ ```jsx
1768
+ const s = createStyles({
1769
+ menu: {
1770
+ backgroundColor: '#1c1c22', // works everywhere
1771
+ borderWidth: 1,
1772
+ borderColor: '#3a3a44',
1773
+ '@supports transparency': {
1774
+ backgroundColor: 'rgba(24, 24, 30, 0.86)',
1775
+ borderRadius: 14,
1776
+ borderWidth: 0,
1777
+ },
1778
+ },
1779
+ });
1780
+ ```
1781
+
1782
+ Write the design that works everywhere as the base and the enhancement in
1783
+ the block — the same way you would write a `@media` query for a wider
1784
+ screen. That ordering is the point: get it the other way round and the
1785
+ fallback is the _unusual_ case, which is the one nobody tests.
1786
+
1787
+ `transparency` is true only when **both** halves hold: the window was
1788
+ created on a 32-bit visual (`<window transparent>`, and the display had one
1789
+ to give), **and** a compositor is running to blend it. Either half missing
1790
+ and a transparent corner is a _black_ corner — X shows the raw pixels — so
1791
+ the honest answer is no, and the block does not apply.
1792
+
1793
+ It is answered **per window**, not per display. A component rendered inside
1794
+ a `<popup transparent>` gets the translucent design; the same component
1795
+ nested in a plain `<window>` gets the opaque one, without being told which
1796
+ it is in.
1797
+
1798
+ The answer can change while the app runs — a compositor being started or
1799
+ stopped is a checkbox on some desktops — and blocks are re-resolved when it
1800
+ does. That is why `transparent` takes the 32-bit visual even when nothing is
1801
+ compositing yet: a window's _visual_ is fixed at creation and cannot follow,
1802
+ but what it paints can.
1803
+
1804
+ For decisions that are not styling — sizing a popup to hold a shadow margin,
1805
+ say, which has to happen before the window exists — there is
1806
+ [`useSupports('transparency')`](elements.md#transparent--rounded-corners-and-translucency),
1807
+ which answers the same question about the display.
1808
+
1809
+ An unknown feature name is an error, like a malformed size query.
1810
+
1811
+ Running with `REACT_X11_NO_TRANSPARENCY=1` makes the answer false on any
1812
+ display, so the base design — the one nobody tests, being the unusual case —
1813
+ can be looked at without stopping the compositor for the whole session. See
1814
+ [debugging.md](debugging.md#react_x11_no_transparency1).
1815
+
1816
+ ## Decided
1817
+
1818
+ - **`':hover'`, not `_hover`.** The CSS spelling costs a pair of quotes and
1819
+ buys transfer from every other styling system.
1820
+ - **Declared containers, not the parent.** `'@container'` answers to the
1821
+ nearest ancestor that _declares_ itself one, never to whatever happens to
1822
+ be the parent: a wrapper `<box>` added for a border must not change a
1823
+ component's answer. CSS spent a decade on element queries before shipping
1824
+ container queries with exactly this requirement. Nor is a scroller a
1825
+ container without saying so — it is the ideal one to declare, but the
1826
+ presence of one between a node and its container must not silently change
1827
+ which box answers. And there is no self query: a node's own size deciding
1828
+ its own layout is the cycle containment exists to forbid.
1829
+ - **Inherited properties, no relational selectors.** `color` and the font
1830
+ properties travel down the tree; nothing matches a rule against it. So
1831
+ there is no `:hover > child`, no sibling combinator and no Tailwind-style
1832
+ `group`. Each of those exists to move a value across the tree, and
1833
+ inheritance already does it in the one direction that is cheap: a parent's
1834
+ `:hover` reaches its children because `color` is inherited, and a child's
1835
+ hover reaches its parents because the hover path is the ancestor chain.
1836
+ What is left over — a **sibling** reacting to a sibling — stays in React,
1837
+ where `useControl` already returns `hover`/`focused`/`pressed` as state
1838
+ ([components.md](components.md#basic-controls)). `<Checkbox>` does exactly
1839
+ that, because its well is a sibling of the label the press lands on.
1840
+ - **`currentColor` resolves where it is painted, not in the style.**
1841
+ Resolving it where a `$token` resolves, once per style swap, would have
1842
+ made every reader of a colour simpler and every inherited change wrong: a
1843
+ `:hover { color }` on a row restyles no node under it, so an ink baked
1844
+ into their styles would be stale in every one. So the style keeps the
1845
+ keyword, as CSS's computed value does, and every reader — the paint on
1846
+ each backend, a Cocoa layer's properties, the blits deciding what a box
1847
+ covers — asks `resolveColor`.
1848
+ - **Sticky sticks to a pane that scrolls, not to anything that clips.** CSS
1849
+ makes every `overflow` but `visible` a scroll container, so a header
1850
+ inside a card with `overflow: hidden` sticks to the card — which never
1851
+ scrolls — and appears to do nothing, the most asked-about sticky bug there
1852
+ is. Here `'hidden'` is CSS's `clip`: it clips and scrolls nothing, and only
1853
+ `overflow: 'scroll'` makes a pane (see
1854
+ [Sticky positioning](#sticky-positioning)). A header inside a rounded card
1855
+ sticks to the list the card scrolls in, and stays inside the card.
1856
+ - **A registered position is a `position` value, not a property of its
1857
+ own.** CSS's `position` already picks a positioning scheme, and sticky is
1858
+ one whose insets mean something other than an offset; a scheme of your own
1859
+ is the same kind of thing. The first spelling, `placement`, was already
1860
+ the anchor vocabulary — which side of its trigger a popup opens on — and
1861
+ threw on the first `<popup placement>` it met. What one property costs is
1862
+ composing a scheme with `absolute`, which a wrapping box answers.
1863
+ - **A layout is a style property, and runs inside the pass.** What a layout
1864
+ decides depends on sizes the pass produces, so a React-side one —
1865
+ measure in `onLayout`, set state — paints the old arrangement first, and
1866
+ misses every pass React never sees: a scroll, a live resize, a
1867
+ transition. A style property is also one a container query block can
1868
+ switch in the same frame.
1869
+ - **The renderer mirrors right to left, not the algorithm.** An algorithm
1870
+ places from the left, once, and each slot is mirrored across the box when
1871
+ it reads right to left — the way yoga mirrors a flex row, so no layout can
1872
+ forget to.
1873
+ - **`null` is unset, and a layout value the layout cannot take is an error
1874
+ where it is written.** `width: wide ? 240 : null` leaves the width to the
1875
+ layout, as `undefined` does — yoga's own setters threw on `null`.
1876
+ `width: 'hidden'`, `padding: 'auto'` or `flexDirection: 'sideways'` is an
1877
+ error at the commit in development, naming the property, and that includes
1878
+ a query block that does not match yet: the block is applied when it starts
1879
+ matching, which is inside a frame, and a throw from there used to take the
1880
+ process down. A value that only turns out wrong in a frame — a `$token`
1881
+ resolving to one — is dropped there and reported, the way CSS drops a
1882
+ declaration it cannot parse, and the rest of the block still applies.
1883
+ - **A colour that does not parse is dropped, not painted.**
1884
+ `backgroundColor: 'nonsense'`, `7` or `'#12'` is an error at the commit in
1885
+ development, naming the property — in the style, in a state block, as a
1886
+ gradient stop or as a shadow's colour. `''`, like `undefined`, means
1887
+ unset. Where one gets past that — a `$token` whose palette entry is not a
1888
+ colour, or any value in a production build — the paint drops it the way
1889
+ CSS drops a declaration it cannot parse: no fill and no stroke, no
1890
+ gradient or shadow at all rather than half of one, and the inherited ink
1891
+ for text. On X11 it used to throw from the frame, where no error boundary
1892
+ could catch it, and the other backends painted it black.
1893
+
1894
+ - **The more specific property wins, not the later one.** CSS resolves a
1895
+ longhand against its shorthand by order. A flattened style has no order
1896
+ left — `[base, { padding: '8px 16px' }]` and `{ paddingTop: 0, … }` are
1897
+ keys of one object by the time anything reads them — so a longhand beats
1898
+ its shorthand and a side beats all four, wherever each is written. It is
1899
+ the rule `paddingLeft` beside `padding: 8` always followed.
1900
+ - **A length is pixels, and a unit nothing reads is an error.** `'12px'` is
1901
+ 12 — scaled for the display like any number, where it used to reach yoga
1902
+ as a string the scale skipped and the border setter read as NaN — and
1903
+ `'2ex'` names its unit rather than becoming 2.
1904
+ - **`rem` is the palette's `fontSize`, not 16.** CSS's root font size is
1905
+ whatever the document's root says, and here that is the theme: the size
1906
+ every label nobody styled is set in. So spacing written in `rem` follows a
1907
+ theme that sets a bigger type size, as the palette's own radii and
1908
+ paddings already do, and an app porting web numbers that assume 16 sets
1909
+ `fontSize: 16` on its theme. The nearest palette is the one read, the way
1910
+ a `$token` is — a `<ThemeProvider value={{ fontSize: 12 }}>` around a
1911
+ dense panel re-bases it.
1912
+
1913
+ ## Elements that are not styled
1914
+
1915
+ The declarative SVG children carry a vocabulary of their own — `<circle cx
1916
+ r fill>` is SVG's, not the style channel's — so the style channel does not
1917
+ apply to them, the same way it does not apply to an `<input type>` in the
1918
+ DOM. They report
1919
+ `stylable === false` and their props are passed through untouched.
1920
+
1921
+ ## Next
1922
+
1923
+ A
1924
+ `boxShadow` that a `<popup>` can cast (the window needs a translucent margin
1925
+ around itself first — see [elements.md](elements.md) on `transparent`
1926
+ popups). Container queries shipped; the design record, with the targeting
1927
+ forms considered and the cost measured, is
1928
+ [architecture/container-queries.md](architecture/container-queries.md).