@qxuken/kui 0.1.0-alpha.36 → 0.1.0-alpha.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +352 -0
- package/docs/adr/0005-the-paint-vocabulary.md +5 -0
- package/docs/adr/0010-a-segment-primitive.md +46 -0
- package/docs/adr/0038-a-scroll-gesture-latches-its-target.md +8 -0
- package/docs/adr/0040-a-path-is-a-mask-in-the-atlas.md +44 -2
- package/docs/adr/0042-a-gradient-is-an-image-the-core-paints.md +455 -0
- package/encoder.js +26 -4
- package/howto.md +79 -1
- package/index.d.ts +23 -0
- package/jsx-runtime.d.ts +58 -3
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/kui_node.node +0 -0
- package/prebuilds/darwin-x64/kui_node.node +0 -0
- package/prebuilds/linux-arm64/kui_node.node +0 -0
- package/prebuilds/linux-x64/kui_node.node +0 -0
- package/prebuilds/win32-x64/kui_node.node +0 -0
- package/props.md +9 -5
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: accepted
|
|
3
|
+
date: 2026-10-05
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# A gradient is an image the core paints: `gradient` on a box, rasterized once into the atlas
|
|
7
|
+
|
|
8
|
+
> **Accepted and built 2026-10-05**, the day it was proposed; the
|
|
9
|
+
> *Amendment* at the end records what the building changed — a malformed
|
|
10
|
+
> gradient is an error where it is declared and not a warning, the
|
|
11
|
+
> atlas's texels are straight alpha, the slot needs a gutter, and the
|
|
12
|
+
> ABI did not bump a second time — and *Measured* what the table of
|
|
13
|
+
> measurements read: the square's size holds, and the bound on a
|
|
14
|
+
> gradient box's cost did not. Asked the
|
|
15
|
+
> day `dash` was (backlog V2): "the next would be gradient backgrounds".
|
|
16
|
+
> [ADR 0005](0005-the-paint-vocabulary.md) declined gradients for v0 and
|
|
17
|
+
> wrote down why — a stop list to parse in five bindings, a type, a
|
|
18
|
+
> geometry, an interpolation space "that looks arbitrary in every
|
|
19
|
+
> choice", and on the wire either a second colour, a direction and a
|
|
20
|
+
> discriminant on the quad or a texture per distinct gradient. Since
|
|
21
|
+
> then [ADR 0015](0015-a-fragment-element-and-the-painter-it-is-not.md)
|
|
22
|
+
> gave the escape hatch (a `fragment` holding children is a gradient
|
|
23
|
+
> card today), [ADR 0025](0025-the-image-is-the-canvas.md) made an image
|
|
24
|
+
> a thing the core stretches over a box with linear sampling, and
|
|
25
|
+
> [ADR 0040](0040-a-path-is-a-mask-in-the-atlas.md) put a shape the core
|
|
26
|
+
> rasterizes into the atlas beside the glyphs. This document takes the
|
|
27
|
+
> second of ADR 0005's two wire shapes and argues it is no longer
|
|
28
|
+
> expensive: the gradient is rasterized on the CPU, once per distinct
|
|
29
|
+
> gradient, into a small image in the atlas, and the box draws it with
|
|
30
|
+
> the `Image` quad every backend already has. No quad kind, no shader
|
|
31
|
+
> change. It supersedes ADR 0005's *Gradients: out of scope for v0* when
|
|
32
|
+
> accepted, and nothing else of that document.
|
|
33
|
+
|
|
34
|
+
## Context
|
|
35
|
+
|
|
36
|
+
- **What a gradient costs an app today.** `add_fragment(wgsl)` and a
|
|
37
|
+
`<fragment>` box: the app writes WGSL, the stops ride in sixteen
|
|
38
|
+
`params`, and on the wire it is a `Fragment` quad — one pipeline
|
|
39
|
+
switch per run of them, and nothing at all in a host whose renderer
|
|
40
|
+
has no WGSL. That is the right price for a ring, noise or a shimmer,
|
|
41
|
+
which nothing else can draw. It is a high one for "this card fades
|
|
42
|
+
from one colour to another", which is what was asked for, and it is
|
|
43
|
+
why `docs/status.md` has to say "there are no gradient props".
|
|
44
|
+
- **An `Image` quad already does the drawing.** It samples atlas RGBA
|
|
45
|
+
over its `rect`, linearly unless told `nearest`, tinted by `color`
|
|
46
|
+
(which is where group opacity goes) and rounded by `radius` "like a
|
|
47
|
+
solid". A horizontal two-stop gradient *is* a strip of texels
|
|
48
|
+
stretched over a box. Nothing about the quad or the backend needs to
|
|
49
|
+
know the texels were computed and not decoded.
|
|
50
|
+
- **The atlas is `Rgba8Unorm` and filtered linearly**, so what the
|
|
51
|
+
sampler interpolates between two texels is the straight sRGB mix —
|
|
52
|
+
the interpolation `Color::lerp` does for a colour transition and the
|
|
53
|
+
one CSS does by default. A stretched ramp and a tween agree.
|
|
54
|
+
- **The core already rasterizes for the atlas.** Paths are masks keyed
|
|
55
|
+
by a hash, made on first sight, shared by every node that draws the
|
|
56
|
+
same one, under the page's reset-and-copy policy. A gradient is the
|
|
57
|
+
same arrangement with four channels.
|
|
58
|
+
- **`bg` is a colour everywhere.** `VisualStyle::bg` is a `Color` on a
|
|
59
|
+
struct every node copies; it is what `transition`, `enter`, `exit`
|
|
60
|
+
and `keyframes` ease, what `hoverBg`, `pressedBg`, `focusBg` and
|
|
61
|
+
`dropBg` replace, what a `$token` resolves into, and a `uint32_t` in
|
|
62
|
+
`KuiSpec`. Widening it to "a colour or a paint" touches all of that
|
|
63
|
+
and the hot struct the frame benches guard.
|
|
64
|
+
- **Stops are a list, and one list-shaped row exists.** `keyframes` is
|
|
65
|
+
a list of stops parsed once in the core from the shared `Value`
|
|
66
|
+
(`keyframes::parse`), spelled as an array in JSX, a table in Lua and
|
|
67
|
+
a struct array in C. ADR 0005 named exactly this as the cost of a
|
|
68
|
+
stop list; it has been paid once.
|
|
69
|
+
|
|
70
|
+
## Decisions
|
|
71
|
+
|
|
72
|
+
1. **`gradient` is a row of its own, on anything that paints a `bg`
|
|
73
|
+
box.** It is not a value of `bg`. The gradient is painted **over**
|
|
74
|
+
`bg` and under the border and the children: `bg` stays a colour,
|
|
75
|
+
shows through transparent stops, and keeps everything it has —
|
|
76
|
+
tweens, state backgrounds, tokens. A box with only a `gradient` is
|
|
77
|
+
one quad; with a visible `bg` under it, two; with a border, one more
|
|
78
|
+
for the ring. `hoverBg` and its siblings replace `bg` as they do
|
|
79
|
+
now and do not touch the gradient: a gradient button's hover is a
|
|
80
|
+
second `gradient` chosen by the view, or a translucent `hoverBg`
|
|
81
|
+
under translucent stops.
|
|
82
|
+
2. **Linear and radial.** A gradient is a type, a geometry and two or
|
|
83
|
+
more stops:
|
|
84
|
+
- linear — a direction, as `to` (`right`, `bottom`, `bottom right`,
|
|
85
|
+
the eight sides and corners) or as `angle` in turns, 0 pointing
|
|
86
|
+
east and running clockwise, the convention `Path::sector` and
|
|
87
|
+
`rotate` already have;
|
|
88
|
+
- radial — a centre `at` (fractions of the box, default the middle)
|
|
89
|
+
and an ellipse that reaches the farthest corner, CSS's default
|
|
90
|
+
`ellipse farthest-corner`.
|
|
91
|
+
|
|
92
|
+
Conic is not in this document (see *Considered options*).
|
|
93
|
+
3. **Stops are colours with optional positions.** `[colour, at]` with
|
|
94
|
+
`at` in 0–1; a stop without one is spaced evenly between its
|
|
95
|
+
neighbours that have, the first defaulting to 0 and the last to 1,
|
|
96
|
+
as CSS spaces them. A position less than the one before it is
|
|
97
|
+
raised to it. A colour is whatever the binding's `bg` takes,
|
|
98
|
+
`$tokens` included, resolved where the row is lowered — so a token
|
|
99
|
+
stop follows the theme because the view is rebuilt when it changes,
|
|
100
|
+
like every other token.
|
|
101
|
+
4. **Geometry is in the box's unit square.** The gradient is defined
|
|
102
|
+
on (0,0)–(1,1) and stretched to the box. For `to right` and
|
|
103
|
+
`to bottom` that is CSS exactly. For a corner it is CSS's corner
|
|
104
|
+
keyword exactly (`to bottom right` puts the 50% line through the
|
|
105
|
+
other two corners, which is what a diagonal in the unit square
|
|
106
|
+
stretched to the box does). For any other `angle` it is **not**
|
|
107
|
+
CSS's `<angle>`, which is measured in pixels and so depends on the
|
|
108
|
+
box's aspect: a kui `angle` of an eighth of a turn runs corner to
|
|
109
|
+
corner at every aspect, where CSS's `45deg` does not. This is the
|
|
110
|
+
decision that makes the rest cheap — see 6 — and the one place the
|
|
111
|
+
spelling means something a web developer has to be told.
|
|
112
|
+
5. **Rasterized on the CPU, into the atlas, as an RGBA image.** A
|
|
113
|
+
linear gradient along an axis is a strip, 256 × 1 or 1 × 256. Every
|
|
114
|
+
other one — an angle, a corner, a radial — is a square, 128 × 128
|
|
115
|
+
(the sizes are the measurement's to confirm). The box draws it with
|
|
116
|
+
one `Image` quad: the box's rect, its radii, linear sampling, the
|
|
117
|
+
group opacity in `color`. No `QuadKind`, no field with a new
|
|
118
|
+
meaning, nothing for `kui-wgpu` or a C host's renderer to learn — a
|
|
119
|
+
host that draws an image draws a gradient.
|
|
120
|
+
6. **Keyed by what it is, not where it is.** The key is a hash of the
|
|
121
|
+
type, the geometry and the resolved stops. Because of 4 the box's
|
|
122
|
+
size, aspect and scale are not in it: a gradient is rasterized once
|
|
123
|
+
and then every node that declares it, at any size, on any monitor,
|
|
124
|
+
through any resize or layout animation, draws the same slot. A
|
|
125
|
+
thousand rows with one gradient are one slot; a heat-mapped list
|
|
126
|
+
with a thousand different ones is a thousand strips, 256 texels
|
|
127
|
+
each.
|
|
128
|
+
7. **Stops interpolate in straight sRGB, alpha premultiplied.** The
|
|
129
|
+
same mix as `Color::lerp`, and CSS's default. The raster computes
|
|
130
|
+
each texel from the stops; the sampler's linear filter between
|
|
131
|
+
texels is the same mix, so the two agree. Alpha is interpolated
|
|
132
|
+
premultiplied, as CSS does, so `transparent` to a colour does not
|
|
133
|
+
pass through grey. Because the raster is the core's and not a
|
|
134
|
+
shader's, another space (`oklab`) is later a value on the row and
|
|
135
|
+
thirty lines in one function, not a branch in every backend — ADR
|
|
136
|
+
0005's "arbitrary in every choice" stops being a wire decision.
|
|
137
|
+
8. **It does not tween.** `transition` eases `bg` under it and
|
|
138
|
+
`opacity` fades it with the box; a `gradient` that differs from last
|
|
139
|
+
frame's is simply a different gradient, drawn at once. One that
|
|
140
|
+
changes every frame is a raster a frame (256 texels, or 16k) and a
|
|
141
|
+
slot a frame, which the page's reset policy absorbs and which is not
|
|
142
|
+
what this is for: a shimmer, a moving sheen and anything driven by
|
|
143
|
+
time stay a `fragment`'s, which reads `time` and re-rasterizes
|
|
144
|
+
nothing.
|
|
145
|
+
9. **Spelled like `keyframes`, parsed once in the core.**
|
|
146
|
+
- JSX: `gradient={{ to: 'bottom', stops: ['#1e2030', '#14161e'] }}`,
|
|
147
|
+
`gradient={{ angle: 0.125, stops: [['$accent', 0], ['#0000', 0.8]] }}`,
|
|
148
|
+
`gradient={{ radial: true, at: [0.5, 0], stops: [...] }}`.
|
|
149
|
+
- Lua: the same table, `at` and stops as `{colour, position}`.
|
|
150
|
+
- Rust: `NodeSpec::gradient(Gradient::linear_to(Side::Bottom, &[...]))`,
|
|
151
|
+
`Gradient::angle(turns, ...)`, `Gradient::radial(...)`, each stop a
|
|
152
|
+
`Color` or `(Color, f32)`.
|
|
153
|
+
- C: `const KuiGradient *gradient` on `KuiSpec` — a type, an angle,
|
|
154
|
+
a centre and a `KuiGradientStop` array, as `keyframes` is a
|
|
155
|
+
`KuiKeyframe` array — NULL for none.
|
|
156
|
+
|
|
157
|
+
A list with fewer than two stops, a `to` nobody spells or a number
|
|
158
|
+
that is not finite draws no gradient and raises
|
|
159
|
+
`gradient-malformed` once per key, as a malformed `d` does.
|
|
160
|
+
10. **Boxes only.** `gradient` is honoured wherever a `bg` paints a
|
|
161
|
+
box. It is not a `path`'s fill, a `polygon`'s, a `line`'s stroke, a
|
|
162
|
+
border's colour or a text's: a path's fill is a one-channel mask
|
|
163
|
+
tinted by one colour, and a gradient under a mask is two textures
|
|
164
|
+
in one quad, which is a different document. Each is listed under
|
|
165
|
+
*Open questions* with what would build it.
|
|
166
|
+
11. **Ghosts, hit-testing, access.** A departing box's ghost carries
|
|
167
|
+
its gradient's key and draws the same slot. The hit region and the
|
|
168
|
+
access row are the box's and do not change. The devtools' node
|
|
169
|
+
inspector shows the row as declared.
|
|
170
|
+
|
|
171
|
+
## Considered options
|
|
172
|
+
|
|
173
|
+
- **Leave it: a gradient is a fragment.** What is built, and still the
|
|
174
|
+
answer for anything animated or procedural. Kept beside this, not
|
|
175
|
+
replaced by it. Rejected as the *only* answer because the commonest
|
|
176
|
+
paint after a flat colour should not need a shader language, a
|
|
177
|
+
pipeline switch per run, or a renderer that compiles WGSL.
|
|
178
|
+
- **A stock gradient fragment**, as `polygon` is a stock fragment: the
|
|
179
|
+
stops in the sixteen params, the WGSL registered by the core. No new
|
|
180
|
+
quad kind, exact at any angle and any stop. But three stops fill the
|
|
181
|
+
params (four floats each, plus geometry), it is still a pipeline
|
|
182
|
+
switch per run — a list of gradient rows interleaved with text is a
|
|
183
|
+
switch per row — and a host without WGSL draws nothing. Not taken.
|
|
184
|
+
- **A `Gradient` quad kind, shaded by the backend.** A ramp strip in
|
|
185
|
+
the atlas, its texels in `uv`, the geometry in the slots a solid does
|
|
186
|
+
not use, and the fragment stage computes `t` per pixel: linear,
|
|
187
|
+
radial and conic are three branches, hard stops are exact, and a CSS
|
|
188
|
+
`<angle>` in pixels is free. This is the better picture. It is also a
|
|
189
|
+
new branch in every renderer of the list, a kind an older host
|
|
190
|
+
silently does not draw, and a second texture read on a path it turns
|
|
191
|
+
hot. Not taken now; it is what *Measurements to take* is deciding
|
|
192
|
+
against, and the row's spelling is the same either way, so it can
|
|
193
|
+
replace decision 5 without changing an app.
|
|
194
|
+
- **A second colour and an angle on the solid quad.** ADR 0005's first
|
|
195
|
+
shape: two stops only, in `blur` and a word of `uv`. Free in quads
|
|
196
|
+
and exact — and three stops are a different mechanism, which is two
|
|
197
|
+
mechanisms. Rejected.
|
|
198
|
+
- **`bg` takes a gradient.** `bg="linear-gradient(…)"`, CSS's own
|
|
199
|
+
spelling. One row instead of two and nothing to learn. But `bg` is a
|
|
200
|
+
`Color` in the style every node carries, the slot five other rows
|
|
201
|
+
replace and four animations ease, and a `uint32_t` in C; every one of
|
|
202
|
+
those then has to say what it does with a paint that is not a colour.
|
|
203
|
+
Rejected for decision 1, which costs a row and leaves them alone.
|
|
204
|
+
- **A CSS string, parsed in the core** as `d` and the size expressions
|
|
205
|
+
are. Attractive for paste-from-a-design-tool, and `$tokens` and the
|
|
206
|
+
four bindings' own colour spellings would each need an answer inside
|
|
207
|
+
the string. Not taken; a `Gradient::parse_css` is an addition the day
|
|
208
|
+
someone pastes one, and does not change the row.
|
|
209
|
+
- **A registered resource**, `addGradient(spec) → id` and
|
|
210
|
+
`gradient={id}`, like a sound or an image. One `u64` a frame and no
|
|
211
|
+
parse. But a gradient is five numbers and two colours, not a megabyte
|
|
212
|
+
of pixels; a handle makes the view name something it could have
|
|
213
|
+
said, makes token stops stale at the next theme change, and makes a
|
|
214
|
+
data-driven list register and remove a thousand of them. Rejected;
|
|
215
|
+
the hash in decision 6 is the handle, and the core holds it.
|
|
216
|
+
- **Rasterize at the box's size**, as a path's mask is, keyed by size
|
|
217
|
+
and scale. Exact hard stops and CSS's pixel angles — and megabytes
|
|
218
|
+
for a window-sized background, a raster on every frame of a resize,
|
|
219
|
+
and the atlas's thrash rule one hero banner away. Rejected for 4
|
|
220
|
+
and 6.
|
|
221
|
+
- **Conic.** A conic gradient's seam is a hard edge at an angle, which
|
|
222
|
+
a 128-texel raster stretched to a box draws as a soft staircase; and
|
|
223
|
+
a conic stretched to a non-square box is not what anyone means. Its
|
|
224
|
+
uses — a colour wheel, a pie, a spinner — are a `path`'s sectors, a
|
|
225
|
+
`rotate` or a fragment already. Not built here; it is the first thing
|
|
226
|
+
the quad-kind option would be reopened for.
|
|
227
|
+
|
|
228
|
+
## Consequences
|
|
229
|
+
|
|
230
|
+
- **A gradient costs an image quad.** No pipeline switch, no texture of
|
|
231
|
+
its own, the same batch as the glyphs around it. The raster is paid
|
|
232
|
+
the first frame a gradient is seen and after an atlas reset.
|
|
233
|
+
- **No renderer changes and no new quad meaning.** `kui-wgpu` is
|
|
234
|
+
untouched; a C host draws gradients the day it links the new
|
|
235
|
+
library. The wire grows a row, not a kind.
|
|
236
|
+
- **Hard stops are soft.** Two stops at one position are an edge in the
|
|
237
|
+
raster, and the raster is stretched: along a strip the edge is the
|
|
238
|
+
box's length over 256 wide (4 px on a 1000 px box), on a square it
|
|
239
|
+
is a 128th of the box each way. A smooth gradient cannot show this;
|
|
240
|
+
stripes can. Stripes are boxes, or a fragment, and the row's doc
|
|
241
|
+
says so.
|
|
242
|
+
- **`angle` is not CSS's.** Decision 4. The doc comment, `props.md` and
|
|
243
|
+
the how-to each say it in one sentence, with the corner case as the
|
|
244
|
+
example.
|
|
245
|
+
- **8-bit banding is what it is everywhere.** A ramp of 8 levels over
|
|
246
|
+
1000 px bands in a browser and bands here; the atlas holds what the
|
|
247
|
+
framebuffer holds. No dither.
|
|
248
|
+
- **Up to three quads for a bordered gradient box over a `bg`.** The
|
|
249
|
+
ring is a solid with a transparent fill, drawn after the image so
|
|
250
|
+
the gradient does not cover the border's inner edge. A `quadCount`
|
|
251
|
+
budget should expect it.
|
|
252
|
+
- **A second row to explain beside `bg`.** "The gradient paints over
|
|
253
|
+
the background" is the sentence; the state backgrounds not replacing
|
|
254
|
+
it is the surprise, and gets a how-to entry.
|
|
255
|
+
- **ABI and wire.** `KuiSpec` gains a pointer and two structs are new,
|
|
256
|
+
so `KUI_ABI_VERSION` bumps; the Node wire bumps for the row's value
|
|
257
|
+
kind. No quad or draw-data layout changes.
|
|
258
|
+
- **ADR 0005 is partly superseded**, and `docs/status.md` loses "no
|
|
259
|
+
gradient props" for "linear and radial on a box; conic, animated and
|
|
260
|
+
anything on a shape are a fragment's".
|
|
261
|
+
|
|
262
|
+
## Open questions
|
|
263
|
+
|
|
264
|
+
- **The atlas's alpha convention.** Decision 7 wants premultiplied
|
|
265
|
+
interpolation; whether the image slots hold straight or premultiplied
|
|
266
|
+
RGBA, and so what the sampler's own filter does between a transparent
|
|
267
|
+
and an opaque texel, is to be read in `atlas.rs` and the shader before
|
|
268
|
+
building, and decides whether the raster stores premultiplied texels
|
|
269
|
+
or straight ones with the colour bled into the transparent side.
|
|
270
|
+
- **The half-texel at each end.** An `Image` quad maps its rect to the
|
|
271
|
+
slot's whole texels, so the outer half-texel of a ramp is flat: a
|
|
272
|
+
512th of the length on a strip, a 256th on a square. Probably
|
|
273
|
+
invisible; if not, the raster extrapolates its end texels by half a
|
|
274
|
+
step rather than the quad learning fractional `uv`.
|
|
275
|
+
- **Does the slot bleed.** Slots are padded a texel; a stretched image
|
|
276
|
+
samples at its edge. Images already do this, so it should hold — to
|
|
277
|
+
confirm with a coverage test, not by reading.
|
|
278
|
+
- **A gradient under a mask**: a `path`'s fill, and with it a
|
|
279
|
+
polygon's, a text's and a stroke's. One quad sampling a mask and a
|
|
280
|
+
ramp is the quad-kind option again. **Condition:** a view that wants
|
|
281
|
+
a gradient-filled shape and cannot stack a fragment — a chart's area
|
|
282
|
+
fill is the likely first.
|
|
283
|
+
- **`gradient` in `keyframes`, `enter` and `exit`.** Not eased
|
|
284
|
+
(decision 8). A crossfade between two slots is two quads and an
|
|
285
|
+
alpha, cheap and not a true interpolation of stops; worth doing only
|
|
286
|
+
if a view asks for a gradient that changes on hover without popping.
|
|
287
|
+
- **`oklab`.** Decision 7 leaves the door; whether it should have been
|
|
288
|
+
the default is a taste question the default-is-CSS answer sidesteps.
|
|
289
|
+
- **Repeating gradients and `bgImage`.** A box whose background is any
|
|
290
|
+
image, with `fit` — CSS's `background-image` — falls out of the same
|
|
291
|
+
three-quad stack and is a row this document deliberately does not
|
|
292
|
+
add; `repeating-linear-gradient` would want it to tile.
|
|
293
|
+
|
|
294
|
+
## Measurements to take
|
|
295
|
+
|
|
296
|
+
| bench or test | what | held to |
|
|
297
|
+
|---|---|---|
|
|
298
|
+
| `frame_10k_rects_with_gradient` | the plain 10k grid, every cell declaring one shared gradient | within 10% of `frame_10k_rects`: the row's parse, a hash and an image quad for a solid one |
|
|
299
|
+
| `frame_1k_distinct_gradients` | 1k rows, each its own strip, steady state | within noise of the above per node: all hits |
|
|
300
|
+
| `raster_gradient_strip`, `raster_gradient_square` | one 256 × 1 and one 128 × 128 raster, three stops | recorded; the square is the number that says what a changing gradient costs a frame |
|
|
301
|
+
| `frame_10k_rects` and the other guarded rows | unchanged trees | unchanged: the row must cost nothing where it is not declared (`NodeSpec` and `emit_node` are the risk, as C41 and C48 found) |
|
|
302
|
+
| `kui-wgpu/tests/gradient_coverage.rs`, two stops | a strip stretched to 1000 px against the mix computed per pixel | every pixel within one 8-bit level |
|
|
303
|
+
| the same, a corner and a radial on a 3:1 box | the 128² square against the per-pixel reference | largest difference recorded; proposed bound two levels for a smooth ramp — the number that confirms or reopens the square's size |
|
|
304
|
+
| the same, a hard stop | the width of the soft edge | recorded, and quoted in the row's doc |
|
|
305
|
+
| the same, transparent to opaque | the midpoint's colour | the premultiplied mix, not grey |
|
|
306
|
+
|
|
307
|
+
If the corner and radial rows cannot be held with a square the atlas
|
|
308
|
+
can afford, the answer is the `Gradient` quad kind under *Considered
|
|
309
|
+
options*, and decisions 1–4 and 6–11 stand as written.
|
|
310
|
+
|
|
311
|
+
## Amendment: what the building changed (2026-10-05)
|
|
312
|
+
|
|
313
|
+
- **A malformed gradient is an error, not a warning.** Decision 9
|
|
314
|
+
proposed `gradient-malformed`, once per key. The row is carried and
|
|
315
|
+
parsed exactly as `keyframes` is, and a `keyframes` list that does not
|
|
316
|
+
parse fails the view in Node and Lua where it is declared; a second
|
|
317
|
+
convention for the row beside it would be the surprise. So: a `to`
|
|
318
|
+
nobody spells, an unknown field, fewer than two stops in the list or
|
|
319
|
+
a number that is not one is an error from `gradient::parse_with`,
|
|
320
|
+
with the field named. What still draws nothing in silence is a
|
|
321
|
+
gradient that *parsed* and has nothing to paint — every stop a token
|
|
322
|
+
that missed (each raised as `unknown-token`), or one built in Rust or
|
|
323
|
+
C with one stop or a NaN. No warning code was added.
|
|
324
|
+
- **The atlas holds straight alpha** (open question 1). An `Image`
|
|
325
|
+
quad's shader multiplies the texel's rgb by the tint and its alpha by
|
|
326
|
+
the coverage separately, so the texels are straight. The raster mixes
|
|
327
|
+
the stops premultiplied, as decision 7 says, and stores the result
|
|
328
|
+
un-premultiplied; a texel with no alpha keeps the straight mix of its
|
|
329
|
+
neighbours' colours, so the sampler does not darken the texel beside
|
|
330
|
+
it. `a_fade_to_transparent_keeps_its_colour` pins the mix.
|
|
331
|
+
- **The slot has a gutter, and it is not empty** (open questions 2
|
|
332
|
+
and 3; see *Measured*). The raster is the strip or the square with
|
|
333
|
+
one texel more all round, each the gradient carried on past the edge,
|
|
334
|
+
and the quad's `uv` is the rect inside. As first built there was no
|
|
335
|
+
gutter, and the coverage test's arithmetic said why there must be
|
|
336
|
+
before the test was written: a stretched quad's sampler reads half a
|
|
337
|
+
texel past the rect it is given, and a strip one texel high, drawn
|
|
338
|
+
over a box forty pixels high, was the gradient only along its middle
|
|
339
|
+
row and faded into whatever the atlas held above and below it.
|
|
340
|
+
- **One table in the atlas, not two.** A gradient's slot lives in the
|
|
341
|
+
keyed table a path's mask does (`get_or_insert_gradient` beside
|
|
342
|
+
`get_or_insert_path`), copied across a reset the same way. The keys
|
|
343
|
+
are hashes of different things; nothing else tells them apart, and
|
|
344
|
+
nothing needs to.
|
|
345
|
+
- **Where the quad goes.** `paint_box` is unchanged — it is on every
|
|
346
|
+
node's path and the frame benches guard it. A cold `paint_gradient`
|
|
347
|
+
runs after it, on frames whose tree has a gradient at all
|
|
348
|
+
(`Tree::any_gradient`), and puts the image where it belongs among the
|
|
349
|
+
quads the box just pushed: after the shadow and the background,
|
|
350
|
+
before the content, with the border lifted off the background's solid
|
|
351
|
+
into a transparent-filled ring above. A ghost reads the same row off
|
|
352
|
+
the spec it kept.
|
|
353
|
+
- **C's stops place themselves with a negative `at`.** `KuiGradientStop`
|
|
354
|
+
is a colour and an `at`; less than zero is "spaced between its
|
|
355
|
+
neighbours", since 0 is a position.
|
|
356
|
+
- **The ABI is 24, not 25.** `dash` had already bumped it in the same
|
|
357
|
+
unreleased section; `gradient` on `KuiSpec` (64-bit size 696) and the
|
|
358
|
+
two structs are in the same step. The Node wire stays v21: the row
|
|
359
|
+
rides as a string reference like `keyframes`, and a new row is not a
|
|
360
|
+
new layout.
|
|
361
|
+
- **`gradient` on a stroke or a fill is ignored**, in the live pass and
|
|
362
|
+
the ghost's: a `line`, a `polygon` and a `path` paint no box.
|
|
363
|
+
|
|
364
|
+
### Measured (2026-10-05, an M3 Pro; backlog V9)
|
|
365
|
+
|
|
366
|
+
`kui-wgpu/tests/gradient_coverage.rs` mirrors the shader's image branch
|
|
367
|
+
on the CPU — the atlas sampled linearly, nothing clamped to the slot —
|
|
368
|
+
over the quads a core emitted, with other gradients in the atlas on
|
|
369
|
+
either side, and compares every pixel of the box with the gradient
|
|
370
|
+
computed at that pixel, in 8-bit levels on the worst channel:
|
|
371
|
+
|
|
372
|
+
| what | box | worst pixel |
|
|
373
|
+
|---|---|---|
|
|
374
|
+
| a strip, each of the four sides | 1000 × 40 | 0.49 |
|
|
375
|
+
| a strip, black to white | 1000 × 40 | 0.50 |
|
|
376
|
+
| the square, a corner | 600 × 200 | 0.48 |
|
|
377
|
+
| the square, `angle` 0.07 | 600 × 200 | 0.52 |
|
|
378
|
+
| the square, a corner, black to white | 600 × 200 | 0.50 |
|
|
379
|
+
| the square, radial from the middle | 600 × 200 | 1.20 |
|
|
380
|
+
| the square, radial from the top edge | 600 × 200 | 0.75 |
|
|
381
|
+
| three stops, strip and corner | as above | 0.50 |
|
|
382
|
+
|
|
383
|
+
Half a level is the rounding of the texels themselves, so a linear
|
|
384
|
+
gradient through 128 texels is exact — a linear ramp is what a linear
|
|
385
|
+
filter reproduces — and **the square's size holds**: the proposed bound
|
|
386
|
+
was two levels, and the worst case, a radial's curvature between
|
|
387
|
+
texels, is 1.2. A hard stop on a strip over 1000 px is 4 px wide. A
|
|
388
|
+
fade to transparent is its colour at every alpha. Without the gutter
|
|
389
|
+
the same tests read 127 and 169 levels at the edges, which is the bug
|
|
390
|
+
the amendment above records.
|
|
391
|
+
|
|
392
|
+
`benches/frame.rs`, medians:
|
|
393
|
+
|
|
394
|
+
| bench | what | median |
|
|
395
|
+
|---|---|---|
|
|
396
|
+
| `frame_10k_rects` | the plain grid | 770 µs |
|
|
397
|
+
| `frame_10k_rects_with_gradient` | every cell's solid a gradient instead, all the same | 1.29 ms |
|
|
398
|
+
| `frame_1k_rects` | 32 × 32, plain | 78.3 µs |
|
|
399
|
+
| `frame_1k_shared_gradient` | the same, one gradient | 131 µs |
|
|
400
|
+
| `frame_1k_distinct_gradients` | the same, a gradient each | 134 µs |
|
|
401
|
+
| `raster_gradient_strip` | 258 × 3 texels, three stops | 4.0 µs |
|
|
402
|
+
| `raster_gradient_square` | 130 × 130, a corner | 24.8 µs |
|
|
403
|
+
| `raster_gradient_radial` | 130 × 130 | 41.9 µs |
|
|
404
|
+
|
|
405
|
+
(These five were taken again by the alpha.37 pre-tag pass, the same
|
|
406
|
+
day. As first measured they read 831 µs, 1.38 ms, 84.1, 140 and 143 µs:
|
|
407
|
+
the bench's grid chose a cell's paint in one `match` with the gradient
|
|
408
|
+
arms in it, and that cost every row built on the grid about 6 ns a
|
|
409
|
+
cell — `frame_10k_rects` 8% over alpha.36's with no line of the core
|
|
410
|
+
between them. The gradient cell is a function of its own now and the
|
|
411
|
+
plain rows are what they were.)
|
|
412
|
+
|
|
413
|
+
**The proposed bound on the first row was not held, and was the wrong
|
|
414
|
+
bound.** "Within 10% of `frame_10k_rects`" assumed a gradient box costs
|
|
415
|
+
what a solid one does. It costs about **52 ns more a node**: half of it
|
|
416
|
+
is the boxed group of rare rows the gradient lives in, which a
|
|
417
|
+
`hoverBg` pays as well (28 ns, measured by giving the plain grid a row
|
|
418
|
+
from that group), and the rest is the stops built and hashed by the
|
|
419
|
+
view every frame, the atlas lookup and the call out of `emit_node`. As
|
|
420
|
+
first built it was 105 ns; the direction is now read off a table for
|
|
421
|
+
the sides and corners instead of a cosine and a sine three times a
|
|
422
|
+
node, and up to four stops are held in place instead of in two lists.
|
|
423
|
+
The rasters were 90 µs and are 25: a square's texels read a ramp mixed
|
|
424
|
+
once instead of each mixing its own.
|
|
425
|
+
|
|
426
|
+
What it means: a card, a header and a row of buttons are microseconds,
|
|
427
|
+
and ten thousand gradient boxes are half a millisecond over ten
|
|
428
|
+
thousand flat ones — not free, and not what the row is for. Distinct
|
|
429
|
+
gradients cost 3 ns a node over a shared one. None of it is the wire
|
|
430
|
+
shape's doing: the quad-kind option would build, hash and box the same
|
|
431
|
+
row. What would lower it is the registered handle *Considered options*
|
|
432
|
+
set aside, or a `Gradient` an app builds once and clones; neither is
|
|
433
|
+
built, and the condition is a view that declares thousands.
|
|
434
|
+
|
|
435
|
+
The guarded rows are within noise of alpha.36 (`bench-check`): a tree
|
|
436
|
+
with no gradient pays one flag test a node.
|
|
437
|
+
|
|
438
|
+
## Action items — all done 2026-10-05
|
|
439
|
+
|
|
440
|
+
1. Read the atlas's alpha convention and its image sampling at a slot's
|
|
441
|
+
edge; settle the first three open questions.
|
|
442
|
+
2. `gradient.rs` in the core: the type, `parse(&Value)`, the stop
|
|
443
|
+
spacing, the two rasters, the hash; unit tests against a per-pixel
|
|
444
|
+
reference.
|
|
445
|
+
3. The atlas slot, the `Image` quad and the three-quad order in
|
|
446
|
+
`emit`; the ghost.
|
|
447
|
+
4. The row in `schema::PROPS` and its four doors; `KuiGradient` and the
|
|
448
|
+
ABI bump; the Node wire bump; `gradient-malformed`.
|
|
449
|
+
5. A `gradients` scene in the corpus; the benches and coverage tests
|
|
450
|
+
above.
|
|
451
|
+
6. The rainbow in `examples/rust/apps/loaders.rs` — a gradient two
|
|
452
|
+
tracks long slid under a clip, one strip for good — in place of the
|
|
453
|
+
`features/gradient.rs` first proposed; the how-to
|
|
454
|
+
entry rewritten ("a gradient: the row; a ring, noise or a shimmer:
|
|
455
|
+
a fragment"); `status.md`, ADR 0005's note, the changelog.
|
package/encoder.js
CHANGED
|
@@ -87,6 +87,23 @@ export function createEncoder(P) {
|
|
|
87
87
|
// The wire index of `$name` in the `kind` space, or `undefined` for a
|
|
88
88
|
// name that resolved to nothing or to the other kind — reported like an
|
|
89
89
|
// unknown prop, and the slot is left out so the core keeps its default.
|
|
90
|
+
// A stroke's `dash` and `dashOffset` as the five floats the stream
|
|
91
|
+
// carries — a mark, a gap, a mark, a gap, the offset — or null for a
|
|
92
|
+
// solid stroke. `dash` is one length (marks and gaps alike), a
|
|
93
|
+
// [mark, gap] pair, or four lengths for a dash-dot (backlog V2).
|
|
94
|
+
function dashOf(p, tag) {
|
|
95
|
+
const d = p.dash;
|
|
96
|
+
const finite = (n) => typeof n === 'number' && Number.isFinite(n);
|
|
97
|
+
if (p.dashOffset !== undefined && !finite(p.dashOffset)) throw new Error(`bad dashOffset ${JSON.stringify(p.dashOffset)} for <${tag}> (px, a number)`);
|
|
98
|
+
if (d == null) return null;
|
|
99
|
+
const l = typeof d === 'number' ? [d] : d;
|
|
100
|
+
if (!Array.isArray(l) || !(l.length === 1 || l.length === 2 || l.length === 4) || !l.every(finite)) {
|
|
101
|
+
throw new Error(`bad dash ${JSON.stringify(d)} for <${tag}> (a length, [mark, gap] or [mark, gap, mark, gap], in px)`);
|
|
102
|
+
}
|
|
103
|
+
const [a, b = a, c = a, e = b] = l;
|
|
104
|
+
return [a, b, c, e, p.dashOffset ?? 0];
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
function tokenRef(v, kind) {
|
|
91
108
|
const name = v.slice(1);
|
|
92
109
|
const hit = ROLE_TOKENS.get(name) ?? tokens?.get(name);
|
|
@@ -597,6 +614,7 @@ export function createEncoder(P) {
|
|
|
597
614
|
case 'tag':
|
|
598
615
|
case 'keyframes':
|
|
599
616
|
case 'enter':
|
|
617
|
+
case 'gradient':
|
|
600
618
|
strRef(JSON.stringify(v));
|
|
601
619
|
break;
|
|
602
620
|
case 'str':
|
|
@@ -1106,16 +1124,18 @@ export function createEncoder(P) {
|
|
|
1106
1124
|
if (p.rotate !== undefined && (typeof p.rotate !== 'number' || !Number.isFinite(p.rotate))) throw new Error(`bad rotate ${JSON.stringify(p.rotate)} for <path> (turns, a number)`);
|
|
1107
1125
|
const pivot = p.pivot;
|
|
1108
1126
|
if (pivot !== undefined && !(Array.isArray(pivot) && pivot.length === 2 && pivot.every((n) => typeof n === 'number' && Number.isFinite(n)))) throw new Error(`bad pivot ${JSON.stringify(pivot)} for <path> ([x, y])`);
|
|
1109
|
-
|
|
1127
|
+
const pathDash = dashOf(p, 'path');
|
|
1128
|
+
reserve(15 + (flat ? flat.length : 0));
|
|
1110
1129
|
f[fi++] = OP.path;
|
|
1111
1130
|
strRef(flat ? null : d);
|
|
1112
1131
|
f[fi++] = flat ? flat.length : 0;
|
|
1113
1132
|
if (flat) for (const n of flat) f[fi++] = n;
|
|
1114
1133
|
f[fi++] = widthRef !== undefined ? widthRef : typeof p.width === 'number' ? p.width : 0;
|
|
1115
|
-
f[fi++] = (widthRef !== undefined ? 1 : 0) | (p.fillRule === 'evenodd' ? 2 : 0) | (p.rotate !== undefined ? 4 : 0) | (pivot !== undefined ? 8 : 0);
|
|
1134
|
+
f[fi++] = (widthRef !== undefined ? 1 : 0) | (p.fillRule === 'evenodd' ? 2 : 0) | (p.rotate !== undefined ? 4 : 0) | (pivot !== undefined ? 8 : 0) | (pathDash ? 16 : 0);
|
|
1116
1135
|
f[fi++] = p.rotate ?? 0;
|
|
1117
1136
|
f[fi++] = pivot ? pivot[0] : 0;
|
|
1118
1137
|
f[fi++] = pivot ? pivot[1] : 0;
|
|
1138
|
+
if (pathDash) for (const n of pathDash) f[fi++] = n;
|
|
1119
1139
|
props(p, el.key, false);
|
|
1120
1140
|
return;
|
|
1121
1141
|
}
|
|
@@ -1135,7 +1155,8 @@ export function createEncoder(P) {
|
|
|
1135
1155
|
// one that does not resolve is left out, the default stroke.
|
|
1136
1156
|
const widthRef = isRef(p.width) ? tokenRef(p.width, 'length') : undefined;
|
|
1137
1157
|
if (p.width !== undefined && typeof p.width !== 'number' && !isRef(p.width)) throw new Error(`bad width ${JSON.stringify(p.width)} for <line> (a stroke width in px, or a "$length")`);
|
|
1138
|
-
|
|
1158
|
+
const lineDash = dashOf(p, 'line');
|
|
1159
|
+
reserve(11 + pts.length * 2);
|
|
1139
1160
|
f[fi++] = OP.line;
|
|
1140
1161
|
f[fi++] = pts.length;
|
|
1141
1162
|
for (const pt of pts) {
|
|
@@ -1146,7 +1167,8 @@ export function createEncoder(P) {
|
|
|
1146
1167
|
f[fi++] = pt[1];
|
|
1147
1168
|
}
|
|
1148
1169
|
f[fi++] = widthRef !== undefined ? widthRef : typeof p.width === 'number' ? p.width : 1;
|
|
1149
|
-
f[fi++] = (p.curve ? 1 : 0) | (widthRef !== undefined ? 2 : 0);
|
|
1170
|
+
f[fi++] = (p.curve ? 1 : 0) | (widthRef !== undefined ? 2 : 0) | (lineDash ? 4 : 0);
|
|
1171
|
+
if (lineDash) for (const n of lineDash) f[fi++] = n;
|
|
1150
1172
|
props(p, el.key, false);
|
|
1151
1173
|
return;
|
|
1152
1174
|
}
|
package/howto.md
CHANGED
|
@@ -52,6 +52,21 @@ a nine-point curve over ~50 px spans is ~60 quads.
|
|
|
52
52
|
[ADR 0010](docs/adr/0010-a-segment-primitive.md) ·
|
|
53
53
|
[alpha.7](CHANGELOG.md#010-alpha7-2026-09-06)
|
|
54
54
|
|
|
55
|
+
### How do I draw a dashed line, a dotted one, or a marquee's marching ants?
|
|
56
|
+
|
|
57
|
+
`dash` on a `<line>` or on a `<path>`'s stroke: `dash={[6, 4]}` is 6 px
|
|
58
|
+
marks with 4 px gaps, `dash={4}` the same length for both, and four
|
|
59
|
+
lengths are a dash-dot. The lengths are what you see — every mark has the
|
|
60
|
+
stroke's round caps — so a mark no longer than the stroke is wide is a
|
|
61
|
+
dot: `width={3} dash={[3, 5]}` is a dotted line. The pattern runs along
|
|
62
|
+
the whole stroke, round corners and along a `curve`. For marching ants,
|
|
63
|
+
grow `dashOffset` from a tick; the marks move towards the first point.
|
|
64
|
+
A dashed line costs a quad per mark, and is still hit in its gaps.
|
|
65
|
+
|
|
66
|
+
[`line` element](props.md#elements) ·
|
|
67
|
+
[ADR 0010's amendment](docs/adr/0010-a-segment-primitive.md#amendment-a-dash-is-cut-in-the-core) ·
|
|
68
|
+
`cargo run --example line`
|
|
69
|
+
|
|
55
70
|
### How do I make a tab bar whose tabs stop shrinking at their labels?
|
|
56
71
|
|
|
57
72
|
Give every tab `width="grow"` and `minWidth="fit"`: they split the bar evenly
|
|
@@ -277,6 +292,23 @@ is the names alone.
|
|
|
277
292
|
[Doors](props.md#doors) ·
|
|
278
293
|
[alpha.21 `### Added`](CHANGELOG.md#010-alpha21-2026-09-26)
|
|
279
294
|
|
|
295
|
+
### How do I choose what stands in for a character my font lacks?
|
|
296
|
+
|
|
297
|
+
Name the fonts, in the order they should be asked:
|
|
298
|
+
`ctx.setFallbackFonts([icons, shipped, mono])` (Rust
|
|
299
|
+
`Core::set_fallback_fonts(&[..])`, C `kui_font_set_fallback`), each an id
|
|
300
|
+
from `addFont`, `addSystemFont` or `loadFontFile`. A character the
|
|
301
|
+
text's own family has no glyph for goes to the first of them that has
|
|
302
|
+
it, and only then to the platform's list — whose first choice on macOS
|
|
303
|
+
is the system's interface face, so Cyrillic in a Latin-only monospaced
|
|
304
|
+
family comes out proportional. The list is the session's, for every
|
|
305
|
+
family and every kind of text; `[]` is the platform's alone. Set it
|
|
306
|
+
when the choice changes, not every frame with a new list: a new list
|
|
307
|
+
shapes every text again (the same one twice costs nothing). A cell grid
|
|
308
|
+
goes one step further by itself: with no fallback of yours that has the
|
|
309
|
+
character it asks a monospaced face before the platform's, and fits and
|
|
310
|
+
centres whatever it got in the cell.
|
|
311
|
+
|
|
280
312
|
### How do I see a font the user installed while the app runs?
|
|
281
313
|
|
|
282
314
|
On macOS and Windows a window app does nothing: the runner hears the OS
|
|
@@ -743,6 +775,31 @@ rows.
|
|
|
743
775
|
[`transition` row](props.md#container-props) ·
|
|
744
776
|
[alpha.17](CHANGELOG.md#010-alpha17-2026-09-25)
|
|
745
777
|
|
|
778
|
+
### How do I zoom with Ctrl or ⌘ and the wheel?
|
|
779
|
+
|
|
780
|
+
Put an `onScroll` on the node that zooms and say which keys it is for:
|
|
781
|
+
`<box onScroll={{ kind: 'zoom' }} scrollMods="ctrl super">` (Rust
|
|
782
|
+
`.on_scroll(tag).scroll_mods(KeyMods::NONE.with_ctrl().with_super())`,
|
|
783
|
+
C `.scroll_mods = KUI_KMOD_CTRL | KUI_KMOD_SUPER`, Lua `scroll_mods =
|
|
784
|
+
"ctrl super"`). A wheel turned with one of them held is then that
|
|
785
|
+
node's `scroll` event wherever under the pointer it began — over a
|
|
786
|
+
list inside it, and the list does not move — and a plain wheel passes
|
|
787
|
+
the node by, so the lists go on scrolling — the node itself too, if it
|
|
788
|
+
is the list: a scroll container that names a key for its own zoom
|
|
789
|
+
scrolls for every other wheel. On the window's root it is
|
|
790
|
+
the whole window's zoom; a canvas inside can name the same key and take
|
|
791
|
+
the gesture for itself, the innermost winning. Positive `dy` is the
|
|
792
|
+
wheel rolling up, "bigger". A mouse's notch is 40 px and a trackpad's
|
|
793
|
+
swipe comes in small pixel steps, so add `dy` up and step when the sum
|
|
794
|
+
crosses your notch rather than once an event. The event's `mods` are
|
|
795
|
+
the keys held when the gesture began: a swipe begun with the key held
|
|
796
|
+
stays yours to the end of its glide even if the key was let go, and
|
|
797
|
+
one begun without it never becomes yours, so there is nothing to latch
|
|
798
|
+
yourself. Without the row an `onScroll` cannot do this: every scroll
|
|
799
|
+
container inside it takes the wheel first.
|
|
800
|
+
|
|
801
|
+
[`scrollMods` row](props.md#container-props)
|
|
802
|
+
|
|
746
803
|
### Why does a swipe down not move the strip sideways?
|
|
747
804
|
|
|
748
805
|
A trackpad swipe keeps to the axis it started on, so nothing is yours
|
|
@@ -1326,7 +1383,28 @@ them sharing an edge show a hairline of the background through it.
|
|
|
1326
1383
|
[ADR 0040](docs/adr/0040-a-path-is-a-mask-in-the-atlas.md) ·
|
|
1327
1384
|
`cargo run --example path` · `cargo run --example polygon`
|
|
1328
1385
|
|
|
1329
|
-
### How do I
|
|
1386
|
+
### How do I give a box a gradient background?
|
|
1387
|
+
|
|
1388
|
+
`gradient` on the box: `gradient={{ to: 'bottom', stops: ['#1e2030',
|
|
1389
|
+
'#14161e'] }}` runs to a side or a corner, `{ angle: 0.125, stops }` along
|
|
1390
|
+
a direction in turns clockwise from east, and `{ radial: true, at: [0.5,
|
|
1391
|
+
0], stops }` out from a centre. A stop is a colour or `[colour, position]`.
|
|
1392
|
+
It paints over `bg` and under the border and the children, so a scrim is
|
|
1393
|
+
a gradient with a transparent stop over whatever is beneath. The geometry
|
|
1394
|
+
is the box's unit square stretched to the box — a corner is CSS's corner,
|
|
1395
|
+
and an `angle` runs corner to corner at an eighth of a turn whatever the
|
|
1396
|
+
aspect, which CSS's `45deg` does not.
|
|
1397
|
+
|
|
1398
|
+
It costs one image quad and is rasterized once per distinct gradient, so
|
|
1399
|
+
it does not tween and `hoverBg` does not replace it: to move a gradient,
|
|
1400
|
+
move the box that has it (the rainbow in `loaders` is one gradient slid
|
|
1401
|
+
under a clip); to animate its colours, write a fragment.
|
|
1402
|
+
|
|
1403
|
+
[`gradient` row](props.md#container-props) ·
|
|
1404
|
+
[ADR 0042](docs/adr/0042-a-gradient-is-an-image-the-core-paints.md) ·
|
|
1405
|
+
`cargo run --example loaders`
|
|
1406
|
+
|
|
1407
|
+
### How do I draw a ring, noise, a shimmer, or anything the paint props cannot?
|
|
1330
1408
|
|
|
1331
1409
|
Write a fragment. `add_fragment(wgsl)` validates one WGSL function and hands
|
|
1332
1410
|
back a handle; `<fragment src={id} params={[…]} animate>` is a box that
|