@solidrt/core 0.0.53 → 0.0.55

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/AGENTS.md CHANGED
@@ -111,7 +111,12 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
111
111
  ```
112
112
 
113
113
  Peer deps @solidjs/signals and @solidjs/universal must match (currently
114
- 2.0.0-rc.3); bun resolves them from peerDependencies.
114
+ 2.0.0-rc.4); bun resolves them from peerDependencies. On a version bump,
115
+ `bun install` only warns ("incorrect peer dependency") and keeps the old
116
+ ones, and `bun update solid-js @solidjs/signals @solidjs/universal` does
117
+ not read the pin either: it adds all three to package.json at registry
118
+ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
119
+ `rm -rf bun.lock node_modules/solid-js node_modules/@solidjs && bun install`.
115
120
 
116
121
  ## Element model (the parts that are easy to get wrong)
117
122
 
@@ -174,6 +179,22 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
174
179
  Avoid pct()/keyword origins on a d-view - they resolve against the box
175
180
  inherited from the nearest laid-out ancestor.
176
181
 
182
+ - Transforms live on views only. A draw primitive (`d-rect`, `d-text`,
183
+ `d-path`, ...) paints in its parent's space and has no `rotate`, `scale`
184
+ or origin of its own; to spin or scale one in place, wrap it in a `d-view`
185
+ carrying the transform, with the origin set in pixels as above. So a
186
+ rotating glyph is two nodes, the view at the pivot and the text placed
187
+ around it - `<d-view x={cx} y={cy} rotate={r}><d-text anchor="middle"
188
+ y={-h / 2}>{ch}</d-text></d-view>`.
189
+
190
+ - A `d-text` label is anchored, not boxed: `anchor="start" | "middle" |
191
+ "end"` makes `x` a point on the line (SVG's text-anchor), the text shapes
192
+ at its natural width (no wrap) and its lines align to the anchor's side.
193
+ Without `anchor` a d-text is a paragraph box wrapping at `w` or at the
194
+ inherited box, and `textAlign` aligns inside that width - so
195
+ `textAlign="right"` on a `w`-less d-text aligns to the ancestor's right
196
+ edge, not to `x`. Reach for `anchor` on chart, axis and diagram labels.
197
+
177
198
  - Layout-affecting vs not (this matters for per-frame work). Props fall in three
178
199
  buckets, split by where they take effect:
179
200
  - `LayoutProps` - width/height, min/max sizes, margin, padding, `position` and
@@ -213,6 +234,23 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
213
234
  inline atom flowing with the words as one unbreakable box on the baseline;
214
235
  give it margins for spacing, since JSX trims the whitespace around it.
215
236
 
237
+ - App-side line breaking, for text a `<text>` box cannot express: text
238
+ poured into a shape, parting around an obstacle that is not in the flow,
239
+ continued across columns, fitted to a box by size, or placed glyph by
240
+ glyph. `prepareText(text, font)` shapes the words once (through the
241
+ shared word cache) into `units` carrying advance, ink width, ascent and
242
+ descent; `layoutNextLine(prepared, cursor, width)` is the greedy breaker,
243
+ returning one line and the `cursor` for the next, at whatever width you
244
+ hand it per call (a chord of a circle, a slot beside an obstacle, the next
245
+ column). Draw each line as a `<d-text>` of exactly its text with the same
246
+ font. Re-breaking every frame is arithmetic plus cache hits, so a moving
247
+ or breathing shape re-flows at frame rate; only the first shaping cost
248
+ anything. `carets: true` adds each unit's per-grapheme x positions - the
249
+ ONLY kerned per-glyph source; per-character `measureText` drops kerning
250
+ and visibly drifts on pairs like AV/TA. Styled runs come from `runs` (draw
251
+ such a line with a `<span>` per run). See examples/text-flow.tsx and
252
+ examples/text-glyphs.tsx.
253
+
216
254
  - Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
217
255
  with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
218
256
  onKeyDown/Up, onTextInput, onFocus/onBlur. Text entry: focus a node with an
@@ -281,9 +319,20 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
281
319
  session in seconds) and it is what keeps per-frame work cheap.
282
320
  - Animation is target-shaped first: declare `transition` on the element and
283
321
  write targets, and the runtime animates natively with no per-frame JS.
322
+ Enter and exit are per-property entries on that same spec: `from` is the
323
+ value the property animates in from at first attach, `exit` the value it
324
+ animates to on removal (the node stays painted until it settles, then
325
+ frees - no AnimatePresence equivalent needed). `stagger` (ms) goes on an
326
+ ANCESTOR, never on the animating elements: it delays each descendant's
327
+ enter/exit by `index * stagger`, and cascades nothing unless the
328
+ descendants declare `from`/`exit`. An enter plays once per mount; to
329
+ replay it, remount the subtree (`<Show when={epoch()} keyed>` around it,
330
+ then bump `epoch`). See examples/stagger.tsx.
284
331
  Reach for per-frame work only for genuinely procedural motion:
285
- `onFrame((tick, frame) => {})` is the native hook (runtime-paced, returns a
286
- cleanup, auto-cleaned inside a reactive scope); `requestAnimationFrame`
332
+ `onFrame((tick, frame, rate) => {})` is the native hook (runtime-paced,
333
+ returns a cleanup, auto-cleaned inside a reactive scope); `rate` is the
334
+ display's nominal refresh rate in Hz, which a fixed-timestep loop needs
335
+ (see @solidrt/cli agents/debugging.md on stepping); `requestAnimationFrame`
287
336
  exists as a web-standard one-shot but is not the preferred driver. A JS
288
337
  tween loop or an animation library pushing interpolated values through
289
338
  signals is the single most expensive mistake available here - read
@@ -306,10 +355,13 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
306
355
 
307
356
  - Device/GPU access via subpath imports: @solidrt/core/camera, /microphone,
308
357
  /speech, /gpu. Image flow: `decodeImage(bytes)` ->
309
- `createTexture(data,w,h)` -> `<texture src={id} />`. Pixels are
310
- premultiplied alpha from decode onward (the GPU contract); `decodeImage(bytes,
311
- { alpha: "straight" })` keeps the file's color under transparent pixels for
312
- CPU work, and `encodeImage` converts back to straight for the file.
358
+ `createTexture(data,w,h)` -> `<texture src={id} />`. Sampling (`filter`,
359
+ `wrap`, `mipmap`) is fixed at creation on every create* helper and follows
360
+ the id everywhere; agents/performance.md rule 2 says when to set which.
361
+ Pixels are premultiplied alpha from decode onward (the GPU contract);
362
+ `decodeImage(bytes, { alpha: "straight" })` keeps the file's color under
363
+ transparent pixels for CPU work, and `encodeImage` converts back to
364
+ straight for the file.
313
365
 
314
366
  ## Minimal app, core primitives only (verified to render)
315
367
 
@@ -38,6 +38,13 @@ no matter how complex the effect, which is why the performance model
38
38
 
39
39
  ## Web reflexes and what replaces them
40
40
 
41
+ - `color-mix()`, `oklch()`, `lab()` -> not in the color grammar (hex,
42
+ `rgb()`, `hsl()`, `hwb()`, named colors only; the prop throws `Invalid
43
+ color` otherwise). Mix in JS with `mixColors(a, b, t)` from @solidrt/core
44
+ (oklab, so a ramp between two colors stays perceptually even) and pass
45
+ the result; `withAlpha(color, a)` gives any color at an opacity (do not
46
+ rebuild `rgba()` strings by hand from a hex); `brightness(color)` picks
47
+ readable text over any fill.
41
48
  - gradient background -> a gradient `color` on a `d-rect` (gradients are
42
49
  paint values, usable anywhere a color is)
43
50
  - `filter: blur/grayscale/hue-rotate`, and any "make this look processed" ->
@@ -60,9 +60,21 @@ Rules, in order of leverage:
60
60
  the flip into its projection) or it draws upside down. Sampling is a
61
61
  create-time option on every texture: `{ filter: "nearest" }` for
62
62
  hard-pixel upscaling (render a small target, display it big - the
63
- retro/pixel-art path) and `{ wrap: "repeat" }` to tile outside 0..1 in
64
- shaders; the defaults are linear and clamp, and the choice applies both
65
- on screen and to shaders sampling the texture.
63
+ retro/pixel-art path), `{ wrap: "repeat" }` to tile outside 0..1 in
64
+ shaders, and `{ mipmap: true }` wherever a shader samples the texture
65
+ smaller than it is (a 3d surface receding into the distance, a target
66
+ read at a fraction of its size): without a mip chain minification skips
67
+ texels and aliases. The chain is regenerated by the runtime after every
68
+ upload and every target render, nothing to schedule, but it is one GPU
69
+ pass each time, so a per-frame texture pays it per frame. Add
70
+ `anisotropy: 8` (any level; the device clamps) beside `mipmap: true` on a
71
+ tiled surface seen at a grazing angle - a road or floor receding to the
72
+ horizon - where trilinear alone smears the far half into the mip its
73
+ long axis chose; it does nothing without the chain. The defaults
74
+ are linear, clamp, no mips and anisotropy 1; the choice is fixed at creation (a change
75
+ means a new id) and applies both on screen and to shaders sampling the
76
+ texture, except that the `<texture>` display draw samples the full-size
77
+ level only, so mips never help a texture merely shown smaller.
66
78
  3. Reduce setProperty calls wherever possible: one path string rebuilt per
67
79
  frame beats N elements with N animated positions; a shader beats the path
68
80
  string. get_stats' setPropsPerFrame is the counter to watch. Compiled JSX
@@ -140,6 +152,13 @@ Rules, in order of leverage:
140
152
  platform the app targets. Use it when the app must call a native
141
153
  library that already exists and already ships for those targets; for
142
154
  speed, everything above comes first.
155
+ 10. Text costs shaping, once per word per style, through a shared word cache;
156
+ line breaking is arithmetic over the shaped words. So re-breaking text
157
+ every frame with prepareText + layoutNextLine (a shape that breathes, an
158
+ obstacle that moves, an editor on every keystroke) is cheap - the lines'
159
+ d-texts hit the same cache - while changing a text's font, size or
160
+ weight re-shapes it. Animate text with transforms and paint (rule 5), or
161
+ by re-breaking; not by resizing it per frame.
143
162
 
144
163
  ## Isolates: heavy work off the JS thread
145
164
 
@@ -49,7 +49,10 @@ to detach from.
49
49
 
50
50
  ## Geometry
51
51
 
52
- Detached geometry is paint-space pixels and never affects layout.
52
+ Detached geometry is paint-space pixels and never affects layout. The
53
+ reverse holds too: a laid-out tile holding only detached geometry must not
54
+ be allowed to flex-shrink (wrap the row, or give it `flexShrink={0}`),
55
+ since the box shrinks and the geometry does not.
53
56
 
54
57
  {{ decl packages/core/src/types.d.ts PositionProps }}
55
58
 
@@ -62,15 +65,19 @@ ancestor, so a `d-rect` with only `x`/`y` still has something to draw:
62
65
 
63
66
  {{ decl packages/core/src/types.d.ts OvalGeometryProps }}
64
67
 
65
- For `d-text`, width is the shaping width and height is reported bounds only,
66
- since a paragraph's height always falls out of the text itself:
68
+ `d-text` is boxed or anchored. Boxed, it wraps at `w` or at the inherited
69
+ box, and `textAlign` aligns lines inside that width. Anchored, `x` is a point
70
+ on the line (SVG's `text-anchor`) and the text does not wrap unless given a
71
+ `w`; that is the form for labels. Its reported bounds are the laid-out
72
+ paragraph, with `w`/`h` overriding a side each:
67
73
 
68
74
  {{ decl packages/core/src/types.d.ts TextGeometryProps }}
69
75
 
70
76
  `d-path` takes only a position, since its size is whatever its `d` string
71
77
  draws. `d-line` has no width and height either: its geometry is its two
72
- endpoints (or, as a polyline, its `points`), which is what makes it the
73
- primitive to reach for when the geometry moves. Both report their bounds
78
+ endpoints (or, as a polyline, its `points`), offset as a whole by `x`/`y`,
79
+ which is what makes it the primitive to reach for when the geometry moves.
80
+ Both report their bounds
74
81
  (getBoundingBox, the tree, a capture) as that geometry plus the stroke, not
75
82
  the inherited box:
76
83
 
@@ -27,12 +27,13 @@ stroke rather than a box.
27
27
 
28
28
  ## Dashing
29
29
 
30
- A stroke's dash pattern, on `line` and `path`:
30
+ A stroke's dash pattern, on every stroked primitive:
31
31
 
32
32
  {{ decl packages/core/src/types.d.ts DashProps }}
33
33
 
34
34
  The pattern is walked along the geometry itself: through a polyline's
35
- vertices and along a path's curves, restarting at each subpath of a path.
35
+ vertices, along a path's curves (restarting at each subpath), and around a
36
+ rect's or oval's inset outline (inside the box, like the solid stroke).
36
37
  `dashOffset` slides it - write it every frame for marching ants, or
37
38
  transition it for a one-shot slide. A dashed stroke keeps its caps on every
38
39
  dash, and a stroke-and-fill path dashes only the stroke.
@@ -47,10 +48,16 @@ the length).
47
48
 
48
49
  {{ decl packages/core/src/types.d.ts RectProps }}
49
50
 
51
+ A dashed rect (a selection marquee, a drop zone) is the dash props on the
52
+ rect itself; see Dashing above. The pattern starts on the top edge after
53
+ the top-left corner and runs clockwise.
54
+
50
55
  ## oval
51
56
 
52
57
  {{ decl packages/core/src/types.d.ts OvalProps }}
53
58
 
59
+ Dashes like a rect; the pattern starts at 3 o'clock and runs clockwise.
60
+
54
61
  ## line
55
62
 
56
63
  {{ decl packages/core/src/types.d.ts LineProps }}
@@ -44,3 +44,37 @@ Inline only: its children are text and other spans, and it has no layout box,
44
44
  which is why it is the one element with no detached form. Pointer handlers on
45
45
  a span fire for the boxes its text occupies on each line it spans, and bubble
46
46
  to the enclosing spans and the text.
47
+
48
+ ## Line breaking in app code
49
+
50
+ A `<text>` wraps in a box. For anything else - text poured into a shape,
51
+ parting around an obstacle that is not in the flow, continued across columns,
52
+ fitted to a box by size, or placed glyph by glyph - the app does the breaking
53
+ itself over words the engine shaped once:
54
+
55
+ ```tsx
56
+ let prepared = prepareText(article, { fontSize: 16, lineHeight: 1.4 })
57
+ let cursor = 0
58
+ for (let band of bands) { // any widths, in any order
59
+ let line = layoutNextLine(prepared, cursor, band.w)
60
+ if (!line) break
61
+ lines.push({ ...band, w: line.width, text: prepared.text.slice(line.start, line.end) })
62
+ cursor = line.cursor // the next box continues here
63
+ }
64
+ ```
65
+
66
+ `prepareText` returns the paragraph's wrap units (a word and its trailing
67
+ whitespace) with their advance, ink width, ascent and descent. `layoutNextLine`
68
+ is a greedy breaker over those numbers: it fills one line to the width you hand
69
+ it and returns the cursor for the next, so a circle is a stack of chords, a
70
+ column is the same loop with a new box, and a headline that must fit is a loop
71
+ over sizes. Each line is drawn as a `<d-text>` of exactly its own text in the
72
+ same font.
73
+
74
+ The split is what makes it usable: shaping happened once, through the shared
75
+ word cache the drawn lines hit again, so re-breaking three paragraphs every
76
+ frame as a shape moves costs arithmetic, not layout. `carets: true` adds each
77
+ unit's per-grapheme x positions from that same shaping - the kerned source for
78
+ per-glyph placement and animation, which measuring characters one at a time
79
+ cannot give. `runs` restyles ranges, with a unit crossing a run boundary coming
80
+ back as glued pieces that always land on one line.
@@ -13,9 +13,14 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
13
13
  - `detached-positioning.tsx` - the `d-` prefix: x/y placement, no reflow, detached-only children.
14
14
  - `text-paint-styling.tsx` - the uniform `color` prop; `drawStyle="stroke"` vs fill.
15
15
 
16
+ ## Text
17
+ - `text-flow.tsx` - app-side line breaking: `prepareText` shapes a paragraph's words once, `layoutNextLine` breaks one line per call at any width. Poured band by band into a circle (each band the chord at its height), then the cursor continues in a column beside it; every line is a `d-text` of its own text. Re-breaks fully on resize for arithmetic plus word-cache hits.
18
+ - `text-glyphs.tsx` - per-glyph positions: `prepareText(text, { carets: true })` reports each unit's kerned grapheme x positions from the shaping that is drawn. One `d-text` per glyph placed from carets sits exactly on the whole headline; the same glyphs placed by per-character `measureText` drift on every kerning pair.
19
+
16
20
  ## Frame and lifecycle
17
21
  - `frame-animation.tsx` - `onFrame` driving a transform animation each frame.
18
22
  - `on-layout-connect.tsx` - `onLayout` + `getBoundingBox` connecting laid-out boxes with a `d-path`.
23
+ - `stagger.tsx` - enter/exit/stagger with no per-frame JS: `from` and `exit` per property on each row's own `transition`, `stagger` on the ANCESTOR cascading them in occurrence order, and the replay idiom - a keyed `<Show>` remounts the subtree when its `when` value changes, so bumping an epoch replays the whole cascade. Exiting rows stay painted until they settle.
19
24
 
20
25
  ## Pointer input
21
26
  - `pointer-local-coords.tsx` - the three pointer coordinate frames (`clientX` window, `localX` the handling node's own frame, `parentX` its path-parent's frame - where the node's x/y live) and the transform-proof drag idiom: grab offset from `localX` at down, place with `parentX - offset` on moves. Exact inside rotated/scaled ancestors and when the pointer leaves the node mid-drag.
@@ -19,6 +19,9 @@
19
19
  // knowing its length.
20
20
  // 5. A laid-out <line points>: the points are content (like a path's `d`), so
21
21
  // the box measures from their extent and takes part in the row.
22
+ // 6. Dashed boxes: the same props on d-rect and d-oval dash the inset
23
+ // outline the solid stroke draws, so a marquee or a drop zone is one
24
+ // element and its dashes never leave the box.
22
25
  // The two-endpoint form (x1..y2, on d-line only) is unchanged; while `points`
23
26
  // is set it takes precedence over the endpoints.
24
27
  import { render, onFrame, createSignal } from "@solidrt/core"
@@ -115,6 +118,24 @@ function App() {
115
118
  </view>
116
119
  </view>
117
120
 
121
+ <text fontSize={16} color="#8b949e">
122
+ dashed boxes: a rounded marquee, a stroke-and-fill rect, an oval drawing on
123
+ </text>
124
+ <view flexDirection="row" flexWrap="wrap" gap={20}>
125
+ <view width={140} height={120}>
126
+ <d-rect radius={8} color="#151b28" />
127
+ <d-rect x={20} y={20} w={100} h={80} radius={12} drawStyle="stroke" onLength={8} offLength={6} dashOffset={-ants()} color="#e3b341" strokeWidth={3} />
128
+ </view>
129
+ <view width={140} height={120}>
130
+ <d-rect radius={8} color="#151b28" />
131
+ <d-rect x={20} y={20} w={100} h={80} drawStyle="stroke-and-fill" onLength={10} offLength={6} color="#f8514966" strokeWidth={6} strokeCap="round" />
132
+ </view>
133
+ <view width={140} height={120}>
134
+ <d-rect radius={8} color="#151b28" />
135
+ <d-oval x={20} y={20} w={100} h={80} drawStyle="stroke" pathLength={1} onLength={drawn()} offLength={1} color="#3fb950" strokeWidth={4} strokeCap="round" />
136
+ </view>
137
+ </view>
138
+
118
139
  <text fontSize={16} color="#8b949e">
119
140
  partial draw: pathLength=1 makes the pattern fractional, 77% of the curve and a triangle drawing on
120
141
  </text>
@@ -0,0 +1,45 @@
1
+ // Enter, exit and stagger are declared on elements; the runtime plays them
2
+ // with no per-frame JS. `from` is the value a property animates in from at
3
+ // first attach, `exit` the value it animates to on removal - the node stays
4
+ // painted until that settles, then frees. Both are per-property entries on
5
+ // the element's own transition spec. `stagger` (ms) goes on an ANCESTOR,
6
+ // never on the animating elements: every descendant enter or exit that
7
+ // begins in the same frame gets index * stagger of extra delay, in
8
+ // occurrence order. It cascades nothing on its own - the descendants must
9
+ // declare from/exit.
10
+ //
11
+ // An enter plays once per mount. To replay it, remount the subtree: a keyed
12
+ // <Show> re-creates its child whenever `when` changes value, so bumping an
13
+ // epoch replays the whole cascade. Tap anywhere: odd taps unmount the list
14
+ // (staggered exits), even taps mount a fresh one (staggered enters).
15
+ import { render, createSignal, Show } from "@solidrt/core"
16
+ import type { Transition } from "@solidrt/core"
17
+
18
+ const ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries"]
19
+ // Extra delay per row before its enter or exit starts.
20
+ const STAGGER_MS = 70
21
+ // Slide in from the left, out to the right; fade both ways.
22
+ const SLIDE = { duration: 500, bounce: 0.2, from: -80, exit: 80 } satisfies Transition
23
+ const FADE = { duration: 350, curve: "ease-out", from: 0, exit: 0 } satisfies Transition
24
+
25
+ function App() {
26
+ let [epoch, setEpoch] = createSignal(1)
27
+
28
+ return (
29
+ <window padding={40} gap={12} onPointerDown={() => setEpoch((e) => e + 1)} transition={{ stagger: STAGGER_MS }}>
30
+ <text color="#8899aa" fontSize={14}>Tap to replay</text>
31
+ <Show when={epoch() % 2 === 1 ? epoch() : 0} keyed>
32
+ {(_epoch) =>
33
+ ROWS.map((label) => (
34
+ <view x={0} opacity={1} transition={{ x: SLIDE, opacity: FADE }} height={48} justifyContent="center" paddingLeft={16}>
35
+ <d-rect color="#2a3a55" radius={10} />
36
+ <text color="#e8eef6" fontSize={18}>{label}</text>
37
+ </view>
38
+ ))
39
+ }
40
+ </Show>
41
+ </window>
42
+ )
43
+ }
44
+
45
+ render(() => <App />)
@@ -0,0 +1,90 @@
1
+ // App-side line breaking. prepareText shapes a paragraph's words once (through
2
+ // the shared word cache) into units carrying advance, ink width, ascent and
3
+ // descent; layoutNextLine then breaks one line out of them per call, at
4
+ // whatever width it is handed, and returns the cursor the next call continues
5
+ // from. The engine never sees a "shape" or a "column": here the paragraph is
6
+ // poured band by band into a circle (each band's width is the chord at that
7
+ // height, its line centered on it) and, when the circle is full, the same
8
+ // cursor continues in a column beside it. Every line is a d-text of exactly its
9
+ // own text, so the whole break redoes on every resize for arithmetic plus
10
+ // word-cache hits; nothing is shaped twice.
11
+ import { For, createMemo, layoutNextLine, prepareText, render, windowSize } from "@solidrt/core"
12
+
13
+ const FONT = { fontSize: 16, lineHeight: 1.4 }
14
+ const PAD = 32
15
+ // Gap between the circle and the column that continues its text.
16
+ const GAP = 32
17
+ // A chord shorter than this holds no word: skip the band at the circle's poles.
18
+ const MIN_BAND = 90
19
+ // Breathing room between the ring and the line ends.
20
+ const INSET = 10
21
+
22
+ const TEXT =
23
+ "A word is shaped once and keeps its width. From there a line is a loop: add words while the pen stays " +
24
+ "inside the width you were given, stop, and hand the cursor to whoever wants the next line. Give each " +
25
+ "call the chord of a circle and the paragraph fills the circle; give the next call a column and it " +
26
+ "continues there, because a column is only the same loop in a new box. The engine shaped the words " +
27
+ "before the first frame and never hears about circles or columns at all. Resize the window: the circle " +
28
+ "grows, every band changes width, and the text re-breaks into it while the words stay as they were. " +
29
+ "That is the whole trick, and it is the oldest one in typesetting: a compositor with a case of type " +
30
+ "never reshaped a letter when the column narrowed. The letters kept their width and only the breaks " +
31
+ "moved. Text engines lost that when a paragraph became one opaque object laid out at one width, and " +
32
+ "every layout question turned into a re-shape. Prepared text gives the idea back, and this page is that " +
33
+ "loop and nothing else."
34
+
35
+ type Placed = { x: number, y: number, w: number, text: string }
36
+
37
+ function App() {
38
+ let prepared = prepareText(TEXT, FONT)
39
+ let lines = createMemo<Placed[]>(() => {
40
+ let { width, height } = windowSize()
41
+ let r = Math.min(width * 0.24, (height - 2 * PAD) / 2)
42
+ let cx = PAD + r
43
+ let cy = height / 2
44
+ let units = prepared.units
45
+ // Single style: every line is as tall as its first unit.
46
+ let lineH = units[0]!.ascent + units[0]!.descent
47
+ let out: Placed[] = []
48
+ let cursor = 0
49
+ // The circle, top to bottom: each band's width is the chord at its middle.
50
+ for (let y = cy - r; y + lineH <= cy + r && cursor < units.length; y += lineH) {
51
+ let dy = y + lineH / 2 - cy
52
+ let half = Math.sqrt(Math.max(0, (r - INSET) * (r - INSET) - dy * dy))
53
+ if (2 * half < MIN_BAND) continue
54
+ let line = layoutNextLine(prepared, cursor, 2 * half)
55
+ if (!line) break
56
+ out.push({ x: cx - line.width / 2, y, w: line.width, text: prepared.text.slice(line.start, line.end) })
57
+ cursor = line.cursor
58
+ }
59
+ // The column beside it continues from the same cursor.
60
+ let colX = cx + r + GAP
61
+ let colW = width - colX - PAD
62
+ for (let y = PAD; y + lineH <= height - PAD && cursor < units.length; y += lineH) {
63
+ let line = layoutNextLine(prepared, cursor, colW)
64
+ if (!line) break
65
+ out.push({ x: colX, y, w: line.width, text: prepared.text.slice(line.start, line.end) })
66
+ cursor = line.cursor
67
+ }
68
+ return out
69
+ })
70
+ let circle = createMemo(() => {
71
+ let { width, height } = windowSize()
72
+ let r = Math.min(width * 0.24, (height - 2 * PAD) / 2)
73
+ return { x: PAD, y: height / 2 - r, d: 2 * r }
74
+ })
75
+ return (
76
+ <window>
77
+ <d-rect color="#f4efe6" />
78
+ <d-oval x={circle().x} y={circle().y} w={circle().d} h={circle().d} drawStyle="stroke" strokeWidth={1} color="#d8b26a" />
79
+ <For each={lines()} keyed={false}>
80
+ {line => (
81
+ <d-text x={line().x} y={line().y} w={line().w + 1} {...FONT} color="#2b2620">
82
+ {line().text}
83
+ </d-text>
84
+ )}
85
+ </For>
86
+ </window>
87
+ )
88
+ }
89
+
90
+ render(() => <App />)
@@ -0,0 +1,73 @@
1
+ // Per-glyph positions. prepareText with `carets: true` reports, per wrap unit,
2
+ // the x of every grapheme boundary from the shaping the engine draws - kerning
3
+ // included - so one d-text per glyph placed from them sits exactly on the whole
4
+ // headline. Measuring characters one at a time with measureText cannot know a
5
+ // glyph's neighbors, so pairs like AV and TA come out loose and the row drifts
6
+ // right. Three rows of the same headline: drawn whole, per glyph from carets,
7
+ // per glyph from measureText. Per-glyph d-texts are what a per-character effect
8
+ // (a wave, a color cycle, a stagger) animates.
9
+ import { measureText, prepareText, render } from "@solidrt/core"
10
+
11
+ const FONT = { fontSize: 48, fontWeight: 800 } as const
12
+ const HEADLINE = "AVATAR Two Ya"
13
+ const LEFT = 32
14
+ const TOP = 40
15
+ const ROW = 96
16
+ // A single glyph never wraps at this width; it only bounds the d-text.
17
+ const GLYPH_W = FONT.fontSize * 2
18
+ const LABEL = { fontSize: 13, color: "#8a8378" }
19
+
20
+ type Glyph = { x: number, text: string }
21
+
22
+ // Kerned: each unit's pen position plus its caret offsets.
23
+ function fromCarets(): Glyph[] {
24
+ let prepared = prepareText(HEADLINE, { ...FONT, carets: true })
25
+ let out: Glyph[] = []
26
+ let pen = 0
27
+ for (let unit of prepared.units) {
28
+ let carets = unit.carets!
29
+ for (let i = 0; i + 1 < carets.length; i++) {
30
+ out.push({ x: pen + carets[i]!.x, text: prepared.text.slice(carets[i]!.offset, carets[i + 1]!.offset) })
31
+ }
32
+ pen += unit.advance
33
+ }
34
+ return out
35
+ }
36
+
37
+ // Unkerned: each character measured alone, advanced by its own width.
38
+ function fromMeasure(): Glyph[] {
39
+ let out: Glyph[] = []
40
+ let x = 0
41
+ for (let ch of HEADLINE) {
42
+ out.push({ x, text: ch })
43
+ x += measureText(ch, FONT).width
44
+ }
45
+ return out
46
+ }
47
+
48
+ function Row(props: { y: number, label: string, glyphs: Glyph[], color: string }) {
49
+ return (
50
+ <d-view x={LEFT} y={props.y}>
51
+ <d-text y={-20} {...LABEL}>{props.label}</d-text>
52
+ {props.glyphs.map(g => (
53
+ <d-text x={g.x} w={GLYPH_W} {...FONT} color={props.color}>{g.text}</d-text>
54
+ ))}
55
+ </d-view>
56
+ )
57
+ }
58
+
59
+ function App() {
60
+ return (
61
+ <window>
62
+ <d-rect color="#f4efe6" />
63
+ <d-view x={LEFT} y={TOP}>
64
+ <d-text y={-20} {...LABEL}>whole headline</d-text>
65
+ <d-text {...FONT} color="#2b2620">{HEADLINE}</d-text>
66
+ </d-view>
67
+ <Row y={TOP + ROW} label="per glyph from carets: true (kerned)" glyphs={fromCarets()} color="#2b2620" />
68
+ <Row y={TOP + 2 * ROW} label="per glyph from measureText per character (drifts)" glyphs={fromMeasure()} color="#b3452b" />
69
+ </window>
70
+ )
71
+ }
72
+
73
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.53",
3
+ "version": "0.0.55",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -29,11 +29,11 @@
29
29
  "AGENTS.md"
30
30
  ],
31
31
  "devDependencies": {
32
- "@solidrt/flux-types": "0.0.53"
32
+ "@solidrt/flux-types": "0.0.55"
33
33
  },
34
34
  "peerDependencies": {
35
- "@solidjs/signals": "2.0.0-rc.3",
36
- "@solidjs/universal": "2.0.0-rc.3",
37
- "solid-js": "2.0.0-rc.3"
35
+ "@solidjs/signals": "2.0.0-rc.4",
36
+ "@solidjs/universal": "2.0.0-rc.4",
37
+ "solid-js": "2.0.0-rc.4"
38
38
  }
39
39
  }
package/src/color.ts CHANGED
@@ -26,6 +26,23 @@ export function mixColors(a: string, b: string, t: number): string {
26
26
  return tree.mixColors(a, b, t)
27
27
  }
28
28
 
29
+ // Alpha byte range of the packed 0xRRGGBBAA color.
30
+ const ALPHA_MAX = 255
31
+
32
+ /**
33
+ * The same CSS color at opacity `alpha` (0 = transparent, 1 = opaque),
34
+ * replacing whatever alpha the input carried. Returns `#rrggbbaa`. Accepts
35
+ * any color `parseColor` does, so a named color or an `rgb()` is fine. For a
36
+ * transparent variant of a theme color prefer this over an `rgba()` literal
37
+ * rebuilt by hand; for a tone that must not depend on what is drawn beneath,
38
+ * `mixColors` against the backdrop.
39
+ */
40
+ export function withAlpha(color: string, alpha: number): string {
41
+ let rgb = parseColor(color) >>> 8
42
+ let a = Math.round(Math.min(1, Math.max(0, alpha)) * ALPHA_MAX)
43
+ return "#" + rgb.toString(16).padStart(6, "0") + a.toString(16).padStart(2, "0")
44
+ }
45
+
29
46
  /**
30
47
  * Perceived brightness of a CSS color, 0 (black) to 1 (white), YIQ-weighted.
31
48
  * Compare a text color against its backdrop to decide rendering polarity,
package/src/core.ts CHANGED
@@ -352,11 +352,19 @@ export function measureText(text: string, options?: tree.MeasureTextOptions): {
352
352
  }
353
353
 
354
354
  /**
355
- * Segments `text` into wrap units (words with their trailing whitespace) and
356
- * shapes each in the given font, once, for laying lines out in app code with
357
- * layoutNextLine or arithmetic of your own over `units`. For the non-standard
358
- * case (text into a shape, around a moving obstacle, handed between columns,
359
- * fitted by size); regular text of any length is a <text>.
355
+ * App-side line breaking. Segments `text` into wrap units (words with their
356
+ * trailing whitespace) and shapes each in the given font, once, through the
357
+ * shared word cache; laying lines out is then arithmetic over `units`, with
358
+ * layoutNextLine or a loop of your own. This is how text goes into a shape,
359
+ * parts around an obstacle that is not in the flow, pours across columns
360
+ * (layoutNextLine's `cursor` continues a paragraph in the next box), fits a
361
+ * box by trying sizes, and gets per-glyph positions (`carets: true`; the
362
+ * only kerned source, see TextUnit.carets). Draw each line as a d-text of
363
+ * exactly its own text. Re-breaking every frame is cheap - the words are
364
+ * shaped already and the lines' d-texts hit the same cache - so a shape
365
+ * that moves or breathes can re-flow at frame rate. A paragraph that just
366
+ * wraps in a box is a <text>, which also does floats, balancing and
367
+ * ellipsis.
360
368
  */
361
369
  export function prepareText(text: string, options?: tree.MeasureTextOptions): tree.PreparedText {
362
370
  return tree.prepareText(text, options)
package/src/gpu.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  // `flux:gpu` module.
12
12
  //
13
13
  // Sampling is a per-texture property declared at creation: `filter`
14
- // ("linear" default | "nearest"), `wrap` ("clamp" default | "repeat") and
15
- // `mipmap` (default false) on every create* helper. One state for every
14
+ // ("linear" default | "nearest"), `wrap` ("clamp" default | "repeat"),
15
+ // `mipmap` (default false) and `anisotropy` (default 1 = off; pair it with
16
+ // mipmap) on every create* helper. One state for every
16
17
  // consumer - `<texture>` display and shader sampling both follow it - so a
17
18
  // nearest texture upscales with hard pixels everywhere (the retro/pixel-art
18
19
  // path: render small, display big). `mipmap: true` keeps a mip chain the
@@ -68,7 +69,7 @@ export type CreateOptions = { autoFree?: boolean; label?: string }
68
69
 
69
70
  // Sampling options every texture-producing create* helper accepts, applied at
70
71
  // creation as a property of the texture id (there is no set-sampler-later).
71
- export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode; mipmap?: boolean }
72
+ export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode; mipmap?: boolean; anisotropy?: number }
72
73
  export type { FilterMode, WrapMode, TextureBinding, TextureBindings } from "flux:gpu"
73
74
 
74
75
  // Pixel format option for the pixel-upload creates (createTexture,
@@ -293,6 +294,10 @@ export function createMutableTexture(
293
294
  * `in vec2 vUV;` yourself to read it. One naming trap: GLSL ES reserves
294
295
  * `packed` as a keyword, so `vec4 packed = texture(...)` fails with a syntax
295
296
  * error that does not name the identifier - pick another name.
297
+ *
298
+ * A bad source is a runtime failure, not one `srt check` catches: the compile
299
+ * throws at this call, so a shader created in a component body takes the app
300
+ * to the error window unless an `<Errored>` closer in the tree claims it.
296
301
  */
297
302
  export function createShaderTexture(
298
303
  fragmentSrc: string,
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from "./renderer"
2
2
  export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, snapshotTexture, onPointerMove } from "./core"
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
- export { parseColor, mixColors, brightness, createLinearGradient, createRadialGradient } from "./color"
4
+ export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
6
  export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
7
7
  export type { BackEvent } from "./window"
@@ -43,6 +43,10 @@ export type {
43
43
  TextEvent,
44
44
  TextInputHints,
45
45
  PaintProps,
46
+ BlendMode,
47
+ DrawStyle,
48
+ StrokeCap,
49
+ StrokeJoin,
46
50
  WindowProps,
47
51
  WindowShaderProps,
48
52
  ViewProps,
@@ -51,11 +55,17 @@ export type {
51
55
  LineProps,
52
56
  PathProps,
53
57
  TextProps,
58
+ FontFamily,
59
+ FontStyle,
60
+ FontWeight,
61
+ TextDecoration,
62
+ TextAlign,
63
+ TextOverflow,
54
64
  TextureProps,
55
65
  Color,
56
66
  Pct,
57
67
  } from "./types"
58
- export type { MeasureTextOptions, PreparedText, TextUnit } from "flux:rendertree"
68
+ export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
59
69
 
60
70
  // A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
61
71
  // half the element box. Keeps percentages a first-class branded value rather
package/src/renderer.ts CHANGED
@@ -125,16 +125,18 @@ export function scanForOrphans(now: number): void {
125
125
  counts.set(node.elementType, (counts.get(node.elementType) ?? 0) + 1)
126
126
  }
127
127
  if (total === 0) return
128
+ // Warn only when a new element type joins the orphans, but list every type
129
+ // with its count so the breakdown always adds up to the total.
128
130
  let fresh = [...counts].filter(([type]) => !warnedLeakTypes.has(type))
129
131
  if (fresh.length === 0) return
130
132
  for (let [type] of fresh) warnedLeakTypes.add(type)
131
- let list = fresh.map(([type, n]) => `<${type}> x${n}`).join(", ")
133
+ let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
132
134
  console.warn(
133
135
  `Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
134
136
  `The usual cause is reading an element-valued prop more than once (every read ` +
135
137
  `builds a new subtree); read it once where it mounts, or resolve it with ` +
136
138
  `children(). If these nodes are intentionally kept for later mounting, ignore ` +
137
- `this. Element types already reported are not reported again.`,
139
+ `this. The next warning comes when a new element type joins the list.`,
138
140
  )
139
141
  }
140
142
 
@@ -261,6 +263,18 @@ let renderer = createRenderer<ProxyNode>({
261
263
  insertNode: (parent: ProxyNode, node: ProxyNode, anchor?: ProxyNode): void => {
262
264
  if (!node) return
263
265
 
266
+ // A value without an id is not a node: a signal accessor (<For>, <Repeat>,
267
+ // a memo) that reached the renderer unresolved. Without this check it
268
+ // surfaces as an FFI type error on node.id, which names nothing. Known
269
+ // cause: okf/upstream/signals-flatten-array-clobbers-needs-unwrap.md.
270
+ if (typeof node !== "object" || node.id === undefined) {
271
+ let what = typeof node === "function" ? "a signal accessor" : `a ${typeof node}`
272
+ throw new Error(
273
+ `insertNode received ${what} instead of an element under <${parent?.elementType ?? "?"}>; ` +
274
+ `resolve the children with children() or return one root element from the component.`,
275
+ )
276
+ }
277
+
264
278
  // A re-inserted node is being moved, not destroyed: cancel its pending
265
279
  // destroy so the end-of-tick sweep leaves it (and its subtree) alone.
266
280
  pendingDestroy.delete(node.id)
package/src/svg.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { parseSvg as fluxParseSvg } from "flux:svg"
8
8
  import { parseColor, type Gradient } from "./color"
9
+ import type { StrokeCap, StrokeJoin } from "./types"
9
10
 
10
11
  /**
11
12
  * Tags an inline SVG source, returning it unchanged. Documents small enough to
@@ -27,8 +28,8 @@ export type SvgDraw = {
27
28
  drawStyle: "fill" | "stroke"
28
29
  fillRule?: "nonzero" | "evenodd"
29
30
  strokeWidth?: number
30
- strokeCap?: "butt" | "round" | "square"
31
- strokeJoin?: "miter" | "round" | "bevel"
31
+ strokeCap?: StrokeCap
32
+ strokeJoin?: StrokeJoin
32
33
  }
33
34
 
34
35
  /** A parsed document: intrinsic size (viewBox/width-height) plus the flat draw list. */
package/src/types.d.ts CHANGED
@@ -148,17 +148,29 @@ export interface LayoutProps extends FlexboxProps, GridProps {
148
148
  clear?: "left" | "right" | "both"
149
149
  }
150
150
 
151
- /** Colors are CSS color strings, parsed to a packed u32 by `parseColor`. */
151
+ /**
152
+ * A CSS color string: hex (`#rgb`, `#rrggbb`, `#rrggbbaa`), `rgb()`/`rgba()`,
153
+ * `hsl()`/`hsla()`, `hwb()`, or a named color. The CSS Color 4 functions -
154
+ * `color-mix()`, `oklch()`, `lab()`, `color()` - are not parsed and throw
155
+ * `Invalid color`. To mix two colors, `mixColors(a, b, t)` (oklab, returns
156
+ * hex); for a color at a given opacity, `withAlpha(color, a)` (returns
157
+ * `#rrggbbaa`); `parseColor` gives the packed u32 the prop also accepts.
158
+ */
152
159
  export type Color = string
153
160
 
161
+ export type BlendMode = "clear" | "source" | "destination" | "source-over" | "destination-over" | "source-in" | "destination-in" | "source-out" | "destination-out" | "source-atop" | "destination-atop" | "xor" | "plus" | "modulate" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "multiply" | "hue" | "saturation" | "color" | "luminosity"
162
+ export type DrawStyle = "fill" | "stroke" | "stroke-and-fill"
163
+ export type StrokeCap = "butt" | "round" | "square"
164
+ export type StrokeJoin = "miter" | "round" | "bevel"
165
+
154
166
  export interface PaintProps {
155
167
  // A solid color, or a gradient from createLinearGradient/createRadialGradient.
156
168
  color?: Color | Gradient
157
- blendMode?: "clear" | "source" | "destination" | "source-over" | "destination-over" | "source-in" | "destination-in" | "source-out" | "destination-out" | "source-atop" | "destination-atop" | "xor" | "plus" | "modulate" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "multiply" | "hue" | "saturation" | "color" | "luminosity"
169
+ blendMode?: BlendMode
158
170
  /** Default "fill"; "stroke" on line, whose segment has no interior (see LineProps). */
159
- drawStyle?: "fill" | "stroke" | "stroke-and-fill"
160
- strokeCap?: "butt" | "round" | "square"
161
- strokeJoin?: "miter" | "round" | "bevel"
171
+ drawStyle?: DrawStyle
172
+ strokeCap?: StrokeCap
173
+ strokeJoin?: StrokeJoin
162
174
  strokeMiter?: number
163
175
  strokeWidth?: number
164
176
  }
@@ -357,22 +369,41 @@ export interface OvalGeometryProps extends PositionProps {
357
369
  h?: number
358
370
  }
359
371
 
360
- /** See {@link PositionProps}: detached-only, never affects layout. */
372
+ /**
373
+ * See {@link PositionProps}: detached-only, never affects layout. A d-text
374
+ * is boxed or anchored. Boxed (no `anchor`): it wraps at `w`, or at the box
375
+ * inherited from the nearest laid-out ancestor, and `textAlign` aligns
376
+ * lines inside that width - so a right-aligned label needs a `w`. Anchored:
377
+ * `x` is a point on the line (SVG's text-anchor), which is what a label on a
378
+ * chart, an axis or a diagram wants. Either way the reported bounds
379
+ * (getBoundingBox, the tree) are the laid-out paragraph - widest line by
380
+ * line stack - unless `w`/`h` override a side.
381
+ */
361
382
  export interface TextGeometryProps extends PositionProps {
362
- // Shaping (wrap) width. Detached text wraps at the inherited ancestor size
363
- // by default; set w for an unwrapped natural line or an explicit wrap width.
383
+ /**
384
+ * Where `x` sits on each line: its start, middle or end. With `w` unset
385
+ * the text shapes at its natural width (no wrap; `\n` still breaks), and
386
+ * `textAlign` defaults to the anchor's side. With `w` set, the `w`-wide
387
+ * box is what gets anchored at `x`.
388
+ */
389
+ anchor?: "start" | "middle" | "end"
390
+ // Shaping (wrap) width; unset, boxed text wraps at the inherited box and
391
+ // anchored text does not wrap.
364
392
  w?: number
365
- // Reported-bounds height only; paragraph height always falls out of the text.
393
+ // Reported-bounds height override only; paragraph height always falls out
394
+ // of the text.
366
395
  h?: number
367
396
  }
368
397
 
369
398
  /**
370
- * See {@link PositionProps}: detached-only, never affects layout. A line's
371
- * reported bounds (getBoundingBox, the tree, a detached capture) are its
372
- * painted box: the geometry's extent plus the stroke's reach, not the
399
+ * See {@link PositionProps}: detached-only, never affects layout. The
400
+ * endpoints (or `points`) are the line's own geometry; `x`/`y` offset all of
401
+ * it, the way a `d-path`'s offset its `d`, so one write moves a polyline. A
402
+ * line's reported bounds (getBoundingBox, the tree, a detached capture) are
403
+ * its painted box: the geometry's extent plus the stroke's reach, not the
373
404
  * inherited box.
374
405
  */
375
- export interface LineGeometryProps {
406
+ export interface LineGeometryProps extends PositionProps {
376
407
  /** Endpoints default to spanning the box: (0,0) to (box width, box height). */
377
408
  x1?: number
378
409
  y1?: number
@@ -520,7 +551,9 @@ export interface TransitionProps {
520
551
  * its transform); the initial value never animates unless the entry sets
521
552
  * `from` (an enter animation), and a non-numeric write (e.g. null)
522
553
  * cancels the running animation and snaps. `null` clears the
523
- * declaration; already-running animations finish.
554
+ * declaration; already-running animations finish. A spec built in a
555
+ * conditional widens `curve` to `string` for TypeScript; write
556
+ * `satisfies Transition` on the branch.
524
557
  */
525
558
  transition?:
526
559
  | ({
@@ -543,7 +576,7 @@ export interface TransitionProps {
543
576
 
544
577
  // Primitives
545
578
 
546
- export interface WindowProps extends LayoutProps, PointerProps {
579
+ export interface WindowProps extends LayoutProps, PointerProps, TransitionProps {
547
580
  children?: Children
548
581
  title?: string
549
582
  fullscreen?: boolean
@@ -563,10 +596,11 @@ export interface WindowProps extends LayoutProps, PointerProps {
563
596
  * `uniform sampler2D uSource` (top-left origin, like every sampled texture -
564
597
  * so a vertex stage mapping it onto the window flips the v coordinate) and
565
598
  * is drawn attributeless as triangles, `vertexCount` vertices fetched via
566
- * gl_VertexID. `iResolution`, filled by name, is the window size in physical
567
- * pixels (the pass covers exactly that). The window is cleared to opaque
568
- * black first, so geometry that does not cover it still presents a defined
569
- * frame.
599
+ * gl_VertexID. `iResolution`, the window size in physical pixels (the pass
600
+ * covers exactly that), is filled by name into a uniform the program declares
601
+ * itself or gets from compileShader's `header` option. The window is cleared
602
+ * to opaque black first, so geometry that does not cover it still presents a
603
+ * defined frame.
570
604
  */
571
605
  export interface WindowShaderProps {
572
606
  /** Linked program handle from linkProgram. */
@@ -673,8 +707,9 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
673
707
  * A boundary shader declaration. The program contract matches shader targets,
674
708
  * not the window pass: the subtree's rasterization binds as
675
709
  * `uniform sampler2D uSource` (top-left origin, like every sampled texture)
676
- * and the pass draws the covering triangle attributeless. `iResolution`,
677
- * filled by name, is the boundary in physical pixels.
710
+ * and the pass draws the covering triangle attributeless. `iResolution`, the
711
+ * boundary in physical pixels, is filled by name into a uniform the program
712
+ * declares itself or gets from compileShader's `header` option.
678
713
  */
679
714
  export interface ViewShaderProps {
680
715
  /** Linked program handle from linkProgram. */
@@ -719,8 +754,9 @@ export interface ViewProps extends ViewOwnProps, LayoutProps {}
719
754
  // A stroked rect paints inside its box, like a CSS border: the stroke's outer
720
755
  // edge sits on the box edge rather than straddling it, so nothing bleeds past
721
756
  // the box for a clip to cut. `path` and `line` strokes stay centered on their
722
- // geometry - there the geometry is the stroke, not a box.
723
- export interface RectProps extends PaintProps, PointerProps {
757
+ // geometry - there the geometry is the stroke, not a box. A dashed stroke
758
+ // dashes that same inset outline (see DashProps).
759
+ export interface RectProps extends PaintProps, PointerProps, DashProps {
724
760
  // Corner radius, measured on the box (the stroke's outer edge). A single
725
761
  // number applies to all four corners; an array is [top-left, top-right,
726
762
  // bottom-right, bottom-left] (CSS border-radius order).
@@ -728,11 +764,15 @@ export interface RectProps extends PaintProps, PointerProps {
728
764
  }
729
765
 
730
766
  // Strokes paint inside the box, same as `RectProps`.
731
- export interface OvalProps extends PaintProps, PointerProps {}
767
+ export interface OvalProps extends PaintProps, PointerProps, DashProps {}
732
768
 
733
769
  /**
734
- * A stroke's dash pattern, on `line` and `path`. Both lengths must be set
735
- * to dash; with either unset, or a gap of 0, the stroke is solid.
770
+ * A stroke's dash pattern, on every stroked primitive (`rect`, `oval`,
771
+ * `line`, `path`). Both lengths must be set to dash; with either unset, or
772
+ * a gap of 0, the stroke is solid. A box primitive dashes its inset
773
+ * outline, the pattern starting where SVG's does (a rect on the top edge
774
+ * after the top-left corner, an oval at 3 o'clock) and running clockwise;
775
+ * its dashes stay inside the box like the solid stroke.
736
776
  */
737
777
  export interface DashProps {
738
778
  /**
@@ -809,13 +849,20 @@ export interface PathProps extends PaintProps, PointerProps, DashProps {
809
849
  fillRule?: "nonzero" | "evenodd"
810
850
  }
811
851
 
852
+ export type FontFamily = "sans" | "serif" | "mono" | (string & {})
853
+ export type FontStyle = "normal" | "italic"
854
+ export type FontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
855
+ export type TextDecoration = "none" | "underline"
856
+ export type TextAlign = "left" | "right" | "center" | "justify"
857
+ export type TextOverflow = "clip" | "ellipsis" | (string & {})
858
+
812
859
  /**
813
860
  * Per-run text style: the paragraph default on <text>, an override on <span>.
814
861
  * The cascade is intra-paragraph only: a span inherits from its enclosing
815
862
  * span, then from the <text>; nothing inherits across the tree.
816
863
  */
817
864
  export interface TextRunProps {
818
- fontFamily?: "sans" | "serif" | "mono" | (string & {})
865
+ fontFamily?: FontFamily
819
866
  fontSize?: number
820
867
  /**
821
868
  * Line height as a MULTIPLIER of fontSize, not pixels (the theme uses
@@ -823,15 +870,15 @@ export interface TextRunProps {
823
870
  * font size, rendering the text as blank space.
824
871
  */
825
872
  lineHeight?: number
826
- fontStyle?: "normal" | "italic"
827
- fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
873
+ fontStyle?: FontStyle
874
+ fontWeight?: FontWeight
828
875
  /**
829
876
  * Underline in the run's own color, drawn straight through descenders
830
877
  * (no skip-ink). Position and thickness come from the font's own metrics
831
878
  * unless overridden; a font Impeller resolves through the system fallback
832
879
  * gets the shipped Noto values.
833
880
  */
834
- textDecoration?: "none" | "underline"
881
+ textDecoration?: TextDecoration
835
882
  /** Pixels from the baseline to the top of the underline. */
836
883
  textUnderlineOffset?: number
837
884
  /** Underline thickness in pixels. */
@@ -851,14 +898,14 @@ export interface SpanProps extends TextRunProps, PointerProps {
851
898
 
852
899
  export interface TextProps extends PaintProps, PointerProps, TextRunProps {
853
900
  children?: Children
854
- textAlign?: "left" | "right" | "center" | "justify"
901
+ textAlign?: TextAlign
855
902
  maxLines?: number
856
903
  /**
857
904
  * What happens to text cut off by maxLines: "clip" (default), "ellipsis"
858
905
  * (a U+2026 at the end of the last line), or any other string to use as
859
906
  * the ellipsis. Drawn in the paragraph's default style.
860
907
  */
861
- textOverflow?: "clip" | "ellipsis" | (string & {})
908
+ textOverflow?: TextOverflow
862
909
  /**
863
910
  * A word (wrap unit) wider than the line: "anywhere" (default) splits it
864
911
  * at grapheme boundaries so it stays inside the box, "normal" keeps it