@solidrt/core 0.0.11 → 0.0.13

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/src/renderer.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { createRoot, createEffect } from "@solidjs/signals"
1
+ import { createRoot } from "@solidjs/signals"
2
2
  import { createRenderer } from "@solidjs/universal"
3
+ import * as tree from "flux:rendertree"
3
4
  import { attachWindow } from "./window"
4
- import { parseColorToU32, setEventHandler, cleanupNodeHandlers, getFocusedNodeId, setFocus } from "./core"
5
+ import { setEventHandler, cleanupNodeHandlers, getFocusedNodeId, setFocus } from "./core"
6
+ import { parseColor, isGradient } from "./color"
5
7
 
6
8
  export { getEventHandler } from "./core"
7
9
 
@@ -47,8 +49,8 @@ export let {
47
49
 
48
50
  // console.debug("[srt] createElement", proxy.id, elementType)
49
51
 
50
- if (elementType === "window") ffi.createRoot(proxy.id)
51
- else ffi.createNode(proxy.id, elementType)
52
+ if (elementType === "window") tree.createRoot(proxy.id)
53
+ else tree.createNode(proxy.id, elementType)
52
54
 
53
55
  return proxy
54
56
  },
@@ -56,14 +58,14 @@ export let {
56
58
  createTextNode: (value: string): ProxyNode => {
57
59
  let proxy = createProxyNode("d-span")
58
60
  // console.debug("[srt] createTextNode", proxy.id, value)
59
- ffi.createNode(proxy.id, "d-span")
60
- ffi.setProperty(proxy.id, "text", "" + value)
61
+ tree.createNode(proxy.id, "d-span")
62
+ tree.setProperty(proxy.id, "text", "" + value)
61
63
  return proxy
62
64
  },
63
65
 
64
66
  replaceText: (node: ProxyNode, value: string): void => {
65
67
  // console.debug("[srt] replaceText", node.id, value)
66
- ffi.setProperty(node.id, "text", "" + value)
68
+ tree.setProperty(node.id, "text", "" + value)
67
69
  },
68
70
 
69
71
  isTextNode: (node: ProxyNode): boolean => node?.elementType === "d-span",
@@ -77,12 +79,17 @@ export let {
77
79
  return
78
80
  }
79
81
 
82
+ if (name === "color" && isGradient(value)) {
83
+ tree.setProperty(node.id, name, value)
84
+ return
85
+ }
86
+
80
87
  if (name === "color" && typeof value === "string") {
81
- ffi.setProperty(node.id, name, parseColorToU32(value))
88
+ tree.setProperty(node.id, name, parseColor(value))
82
89
  return
83
90
  }
84
91
 
85
- ffi.setProperty(node.id, name, value)
92
+ tree.setProperty(node.id, name, value)
86
93
  },
87
94
 
88
95
  insertNode: (parent: ProxyNode, node: ProxyNode, anchor?: ProxyNode): void => {
@@ -104,8 +111,8 @@ export let {
104
111
 
105
112
  // console.debug("[srt] insertNode", parent.id, node.id, anchor?.id ?? "")
106
113
 
107
- if (anchor) ffi.insertNode(parent.id, node.id, anchor.id)
108
- else ffi.insertNode(parent.id, node.id)
114
+ if (anchor) tree.insertNode(parent.id, node.id, anchor.id)
115
+ else tree.insertNode(parent.id, node.id)
109
116
  }
110
117
  },
111
118
 
@@ -121,7 +128,7 @@ export let {
121
128
  }
122
129
  node.parent = undefined
123
130
 
124
- ffi.deleteNode(parent.id, node.id)
131
+ tree.deleteNode(parent.id, node.id)
125
132
 
126
133
  // Recursively clean up node and all descendants. Clear focus before
127
134
  // dropping handlers so onBlur still fires for a focused descendant.
@@ -145,6 +152,12 @@ export let {
145
152
  },
146
153
  })
147
154
 
155
+ /**
156
+ * Mounts a SolidRT app. Call once at the top level: `render(() => <App />)`.
157
+ * The element returned by `code` MUST be a `<window>` (it becomes the native
158
+ * window and root of the render tree); anything else throws. Runs inside a
159
+ * reactive root, so the whole tree is disposed together on engine reload.
160
+ */
148
161
  export function render(code: () => any) {
149
162
  createRoot(() => {
150
163
  let root = code()
@@ -0,0 +1,149 @@
1
+ // Speech recognition. A session captures the microphone, segments utterances
2
+ // by silence (Silero VAD) and transcribes each one with Whisper, delivering
3
+ // final transcripts through onResult. With wakeWord the session starts
4
+ // asleep behind an efficient wake word detector (livekit-wakeword) and only
5
+ // transcribes after the wake word. startRecognition resolves once the models
6
+ // are loaded and listening has begun; it rejects when loading fails.
7
+ // Models are passed as bytes so any source composes: flux:fs file(), fetch
8
+ // (incl. the dev-server file proxy), or a download cache layered on top.
9
+ // Requires a runtime built with speech support.
10
+
11
+ import { createSignal, onCleanup } from "@solidjs/signals"
12
+
13
+ export type SpeechOptions = {
14
+ /** A ggml Whisper model (file contents, e.g. ggml-tiny.en.bin). */
15
+ model: Uint8Array
16
+ /** A ggml Silero VAD model (file contents). */
17
+ vadModel: Uint8Array
18
+ /** Whisper language code; "auto" detects (multilingual models only). Default "en". */
19
+ lang?: string
20
+ /** Explicit microphone device id from listMicrophones(). */
21
+ microphone?: number
22
+ /**
23
+ * Keep transcribing utterance after utterance. Default true. Set false to
24
+ * stop after the first final result (with wakeWord: re-arm instead, one
25
+ * result per wake). Inverse of the Web Speech API default (false there).
26
+ */
27
+ continuous?: boolean
28
+ /** Also deliver snapshot transcripts (final: false) while an utterance is still being spoken. */
29
+ interimResults?: boolean
30
+ /**
31
+ * Wake word: start asleep, fire onWake when it is heard, then transcribe
32
+ * the speech that follows. How the wake word is specified depends on the
33
+ * engine. The current engine detects with a trained classifier and takes
34
+ * the model's bytes (livekit-wakeword ONNX, e.g. the pretrained "hey
35
+ * livekit"; custom phrases are trained offline with its toolkit). Phrase
36
+ * strings are reserved for engines that match text; passing them to this
37
+ * engine rejects with an error.
38
+ */
39
+ wakeWord?: Uint8Array | string | string[]
40
+ /** Detector confidence (0..1) that counts as a wake. Default 0.5. */
41
+ wakeThreshold?: number
42
+ }
43
+
44
+ export type SpeechResult = {
45
+ /** Transcript of the utterance (a snapshot of it when isFinal is false). */
46
+ transcript: string
47
+ /** True for the completed utterance, false for interim snapshots. */
48
+ isFinal: boolean
49
+ }
50
+
51
+ export type SpeechSession = {
52
+ /** Receive transcripts (replaces any previous callback). */
53
+ onResult(callback: (result: SpeechResult) => void): void
54
+ /** The user started speaking (replaces any previous callback). */
55
+ onSpeechStart(callback: () => void): void
56
+ /** The utterance ended; its final result follows once transcribed. */
57
+ onSpeechEnd(callback: () => void): void
58
+ /** The wake word was heard (wakeWordModel sessions only). */
59
+ onWake(callback: () => void): void
60
+ /** Release the microphone and discard any utterance in progress. */
61
+ stop(): void
62
+ }
63
+
64
+ export async function startRecognition(options: SpeechOptions): Promise<SpeechSession> {
65
+ let started = await speech.start(options)
66
+ return {
67
+ onResult: (callback: (result: SpeechResult) => void) => speech.setResultCallback(started.handle, callback),
68
+ onSpeechStart: (callback: () => void) => speech.setSpeechStartCallback(started.handle, callback),
69
+ onSpeechEnd: (callback: () => void) => speech.setSpeechEndCallback(started.handle, callback),
70
+ onWake: (callback: () => void) => speech.setWakeCallback(started.handle, callback),
71
+ stop: () => speech.stop(started.handle),
72
+ }
73
+ }
74
+
75
+ /** A live recognition session as reactive accessors. */
76
+ export type SpeechStream = {
77
+ /** True once the models are loaded and listening has begun. */
78
+ ready(): boolean
79
+ /** Latest transcript, "" until the first result (a snapshot while isFinal is false). */
80
+ transcript(): string
81
+ /** Whether transcript() is the completed utterance rather than an interim snapshot. */
82
+ isFinal(): boolean
83
+ /** True while the user is mid-utterance (between speech start and end). */
84
+ speaking(): boolean
85
+ /** True from when an utterance ends until its transcript arrives. */
86
+ transcribing(): boolean
87
+ /**
88
+ * wakeWord sessions only: true from when the wake word is heard until the
89
+ * following command is transcribed (the next final result), then false.
90
+ * Always false without a wakeWord.
91
+ */
92
+ awake(): boolean
93
+ /** Set if loading or starting failed. */
94
+ error(): Error | undefined
95
+ }
96
+
97
+ /**
98
+ * Starts speech recognition and exposes it as reactive signals: read
99
+ * transcript()/isFinal() for results, speaking() and awake() for session
100
+ * state. Stops when the reactive owner is disposed. The lower-level
101
+ * startRecognition() is the imperative alternative.
102
+ */
103
+ export function createSpeechRecognition(options: SpeechOptions): SpeechStream {
104
+ let [ready, setReady] = createSignal(false)
105
+ let [transcript, setTranscript] = createSignal("")
106
+ let [isFinal, setIsFinal] = createSignal(false)
107
+ let [speaking, setSpeaking] = createSignal(false)
108
+ let [transcribing, setTranscribing] = createSignal(false)
109
+ let [awake, setAwake] = createSignal(false)
110
+ let [error, setError] = createSignal<Error | undefined>(undefined)
111
+ let handle: number | undefined
112
+ let disposed = false
113
+
114
+ speech
115
+ .start(options)
116
+ .then((started) => {
117
+ if (disposed) {
118
+ speech.stop(started.handle)
119
+ return
120
+ }
121
+ handle = started.handle
122
+ speech.setResultCallback(started.handle, (result) => {
123
+ setTranscript(result.transcript)
124
+ setIsFinal(result.isFinal)
125
+ if (result.isFinal) {
126
+ setTranscribing(false)
127
+ setAwake(false)
128
+ }
129
+ })
130
+ speech.setSpeechStartCallback(started.handle, () => setSpeaking(true))
131
+ speech.setSpeechEndCallback(started.handle, () => {
132
+ setSpeaking(false)
133
+ setTranscribing(true)
134
+ })
135
+ speech.setWakeCallback(started.handle, () => setAwake(true))
136
+ setReady(true)
137
+ })
138
+ .catch((e) => setError(e instanceof Error ? e : new Error(String(e))))
139
+
140
+ onCleanup(() => {
141
+ disposed = true
142
+ if (handle !== undefined) {
143
+ speech.stop(handle)
144
+ handle = undefined
145
+ }
146
+ })
147
+
148
+ return { ready, transcript, isFinal, speaking, transcribing, awake, error }
149
+ }
@@ -0,0 +1,207 @@
1
+ // Headless text-input mechanism. These primitives own the objective parts of
2
+ // an editable single-line field -- the value buffer (text + caret/selection)
3
+ // and the scroll-to-caret geometry -- and nothing with a UI opinion. Caret
4
+ // blink, keybindings, placeholder and styling are policy and belong to the
5
+ // component (the "skin") that composes these.
6
+
7
+ import { createSignal, flush } from "@solidjs/signals"
8
+ import { getBoundingBox, measureText } from "./core"
9
+ import { onLayout } from "./window"
10
+
11
+ /**
12
+ * A text selection as anchor/focus character offsets, following the same model
13
+ * as the platform editors (Flutter's TextSelection, the DOM Selection): the
14
+ * anchor is where the selection started, the focus is the moving end where the
15
+ * caret sits. A collapsed selection (anchor === focus) is a plain caret.
16
+ */
17
+ export type Selection = { anchor: number; focus: number }
18
+
19
+ export type MoveDirection = "left" | "right" | "start" | "end"
20
+
21
+ export type TextBufferOptions = {
22
+ /**
23
+ * Controlled value accessor. When it returns a string, the buffer mirrors it
24
+ * and edits flow out only through onInput (the internal text is bypassed).
25
+ * The selection is always buffer-owned editing state regardless.
26
+ */
27
+ value?: () => string | undefined
28
+ /** Initial value when uncontrolled. */
29
+ defaultValue?: string
30
+ /** Called with the new text after every edit, already clamped to maxLength. */
31
+ onInput?: (value: string) => void
32
+ /** Max length; inserts past it are clamped. */
33
+ maxLength?: () => number | undefined
34
+ }
35
+
36
+ export type TextBuffer = {
37
+ /** Current text: the controlled value if provided, else internal state. */
38
+ value(): string
39
+ /** Current selection, clamped to the text length. Collapsed = a caret. */
40
+ selection(): Selection
41
+ /** The focus offset (where the caret sits). */
42
+ caret(): number
43
+ /** Replace the current selection with text, then collapse the caret after it. */
44
+ insertText(text: string): void
45
+ /** Delete the selection if any, else the character before the caret. */
46
+ deleteBackward(): void
47
+ /** Delete the selection if any, else the character after the caret. */
48
+ deleteForward(): void
49
+ /** Move the caret. `extend` keeps the anchor to grow a selection (else collapses). */
50
+ move(direction: MoveDirection, options?: { extend?: boolean }): void
51
+ /** Set the selection directly (offsets are clamped to the text length). */
52
+ setSelection(anchor: number, focus: number): void
53
+ /** Replace the whole value, caret to the end. */
54
+ setValue(next: string): void
55
+ /** Clear to empty. */
56
+ clear(): void
57
+ }
58
+
59
+ /**
60
+ * An editable text buffer that bridges controlled/uncontrolled use and owns the
61
+ * caret/selection. With a `value` accessor the buffer is controlled: edits do
62
+ * not mutate internal text, they only call `onInput` so the owner can update its
63
+ * source. Without one it holds the text itself. The selection is always
64
+ * buffer-owned state and is clamped to the current text length on read, so an
65
+ * external truncation of a controlled value cannot leave the caret dangling.
66
+ * Every edit is clamped to `maxLength`.
67
+ */
68
+ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
69
+ let initial = options.defaultValue ?? ""
70
+ let [internalValue, setInternalValue] = createSignal(initial)
71
+ let [selectionState, setSelectionState] = createSignal<Selection>({
72
+ anchor: initial.length,
73
+ focus: initial.length,
74
+ })
75
+
76
+ let value = () => options.value?.() ?? internalValue()
77
+
78
+ let selection = (): Selection => {
79
+ let len = value().length
80
+ let s = selectionState()
81
+ return { anchor: Math.min(s.anchor, len), focus: Math.min(s.focus, len) }
82
+ }
83
+
84
+ // Ordered selection bounds [start, end).
85
+ let range = () => {
86
+ let { anchor, focus } = selection()
87
+ return anchor <= focus ? [anchor, focus] : [focus, anchor]
88
+ }
89
+
90
+ let setCaret = (offset: number) => setSelectionState({ anchor: offset, focus: offset })
91
+
92
+ // Apply a text edit and place the caret, clamping to maxLength.
93
+ let apply = (next: string, caret: number) => {
94
+ let max = options.maxLength?.()
95
+ if (max != null && next.length > max) next = next.slice(0, max)
96
+ caret = Math.min(caret, next.length)
97
+ if (options.value?.() == null) setInternalValue(next)
98
+ setCaret(caret)
99
+ options.onInput?.(next)
100
+ }
101
+
102
+ return {
103
+ value,
104
+ selection,
105
+ caret: () => selection().focus,
106
+
107
+ insertText: (text) => {
108
+ let v = value()
109
+ let [start, end] = range()
110
+ apply(v.slice(0, start) + text + v.slice(end), start + text.length)
111
+ },
112
+
113
+ deleteBackward: () => {
114
+ let v = value()
115
+ let [start, end] = range()
116
+ if (start !== end) apply(v.slice(0, start) + v.slice(end), start)
117
+ else if (start > 0) apply(v.slice(0, start - 1) + v.slice(start), start - 1)
118
+ },
119
+
120
+ deleteForward: () => {
121
+ let v = value()
122
+ let [start, end] = range()
123
+ if (start !== end) apply(v.slice(0, start) + v.slice(end), start)
124
+ else if (end < v.length) apply(v.slice(0, end) + v.slice(end + 1), end)
125
+ },
126
+
127
+ move: (direction, opts) => {
128
+ let extend = opts?.extend ?? false
129
+ let { anchor, focus } = selection()
130
+ let len = value().length
131
+ // A non-extending left/right on a range collapses to the near edge.
132
+ if (!extend && anchor !== focus && (direction === "left" || direction === "right")) {
133
+ setCaret(direction === "left" ? Math.min(anchor, focus) : Math.max(anchor, focus))
134
+ return
135
+ }
136
+ let next = focus
137
+ if (direction === "left") next = Math.max(0, focus - 1)
138
+ else if (direction === "right") next = Math.min(len, focus + 1)
139
+ else if (direction === "start") next = 0
140
+ else if (direction === "end") next = len
141
+ setSelectionState({ anchor: extend ? anchor : next, focus: next })
142
+ },
143
+
144
+ setSelection: (anchor, focus) => {
145
+ let len = value().length
146
+ setSelectionState({ anchor: Math.min(anchor, len), focus: Math.min(focus, len) })
147
+ },
148
+
149
+ setValue: (next) => apply(next, next.length),
150
+ clear: () => apply("", 0),
151
+ }
152
+ }
153
+
154
+ export type CaretScrollInput = {
155
+ text: string
156
+ fontSize: number
157
+ /** Caret offset into `text`. Defaults to the text end. */
158
+ caret?: number
159
+ /** Px reserved so the caret stays visible at the viewport edge. Default 0. */
160
+ caretWidth?: number
161
+ }
162
+
163
+ /**
164
+ * Returns the horizontal scroll offset that keeps the caret within the viewport
165
+ * node. The offset is retained between frames and only adjusted when the caret
166
+ * would fall outside the visible range (scrolled left when the caret runs past
167
+ * the right edge, right when it moves before the left edge), so stationary text
168
+ * does not jump. The viewport width and offset are computed in onLayout and the
169
+ * synchronous flush drains the update before paint, so the scroll tracks a caret
170
+ * or width change in the same frame. Pure geometry: no caret rendering and no
171
+ * placeholder/visual policy.
172
+ */
173
+ export function createCaretScroll(
174
+ viewport: () => { id: number } | undefined,
175
+ input: () => CaretScrollInput,
176
+ ): () => number {
177
+ let [scrollX, setScrollX] = createSignal(0)
178
+
179
+ onLayout(() => {
180
+ let node = viewport()
181
+ if (!node) return
182
+ let vw = getBoundingBox(node)?.width ?? 0
183
+ let { text, fontSize, caret, caretWidth = 0 } = input()
184
+ let len = text.length
185
+ let c = caret == null ? len : Math.max(0, Math.min(caret, len))
186
+
187
+ let totalWidth = measureText(text, { fontSize }).width
188
+ let caretX = c >= len ? totalWidth : measureText(text.slice(0, c), { fontSize }).width
189
+ let maxScroll = Math.max(0, totalWidth + caretWidth - vw)
190
+
191
+ let cur = scrollX()
192
+ let next = cur
193
+ if (vw <= 0) {
194
+ next = 0
195
+ } else if (caretX < cur) {
196
+ next = caretX
197
+ } else if (caretX + caretWidth > cur + vw) {
198
+ next = caretX + caretWidth - vw
199
+ }
200
+ next = Math.max(0, Math.min(next, maxScroll))
201
+
202
+ if (next !== cur) setScrollX(next)
203
+ flush()
204
+ })
205
+
206
+ return scrollX
207
+ }
package/src/types.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference types="@solidrt/flux-types" />
2
2
 
3
3
  import type { JSX as SolidJSX } from "@solidjs/signals"
4
+ import type { Gradient } from "./color"
4
5
 
5
6
  // UI event bus (lattice), provided by the runtime as a builtin module.
6
7
  // on/once return an unsubscribe function.
@@ -20,59 +21,26 @@ declare module "srt:dev" {
20
21
  export function stop(): void
21
22
  }
22
23
 
23
- declare global {
24
- function requestAnimationFrame(callback: (time: number) => void): number
25
- function cancelAnimationFrame(id: number): void
26
-
27
- let ffi: {
28
- createRoot(id: number): void
29
- createNode(id: number, kind: string): void
30
- insertNode(parentId: number, nodeId: number, anchorId?: number): void
31
- deleteNode(parentId: number, nodeId: number): void
32
- setProperty(nodeId: number, name: string, value: unknown): void
33
- setTextInputActive(active: boolean): void
34
- requestFrame(): void
35
- measureText(text: string, options?: MeasureTextOptions): { width: number, height: number }
36
- getBoundingBox(id: number): { x: number, y: number, width: number, height: number } | null
37
- }
24
+ // Frame draw (lattice runner). renderFrame() synchronously renders the current
25
+ // frame: layout, the postLayout hook, paint and hover refresh, then builds and
26
+ // submits the display list. To schedule a future frame instead, use
27
+ // requestFrame() from "flux:rendertree". The tree-building surface itself is
28
+ // "flux:rendertree" (from @solidrt/flux-types).
29
+ declare module "srt:render" {
30
+ export function renderFrame(): void
31
+ }
38
32
 
39
- let gpu: {
40
- createTexture(data: Uint8Array, width: number, height: number): number
41
- createMutableTexture(data: Uint8Array, width: number, height: number): number
42
- uploadTexture(textureId: number, offset?: number): void
43
- createShader(
44
- fragmentSrc: string,
45
- width: number,
46
- height: number,
47
- params?: Record<string, number>,
48
- textures?: Record<string, number>,
49
- ): number
50
- setShaderParams(textureId: number, params: Record<string, number>): void
33
+ declare global {
34
+ let image: {
51
35
  decodeImage(bytes: Uint8Array): { data: Uint8Array, width: number, height: number }
52
36
  }
53
37
 
54
- let camera: {
55
- listCameras(): { id: number, name: string, facing: "front" | "back" | "unknown" }[]
56
- open(options: { camera?: number, facing?: "front" | "back", width?: number, height?: number, scan?: string[] }):
57
- Promise<{ handle: number, texture: number, width: number, height: number }>
58
- setBarcodeCallback(handle: number, callback: (result: { data: string, format: "qr" }) => void): void
59
- scanImage(data: Uint8Array, width: number, height: number): { data: string, format: "qr" }[]
60
- close(handle: number): void
61
- }
62
-
63
- let microphone: {
64
- listMicrophones(): { id: number, name: string }[]
65
- open(options: { microphone?: number, sampleRate?: number }): { handle: number, sampleRate: number }
66
- read(handle: number): Float32Array
67
- close(handle: number): void
68
- }
69
-
70
38
  let speech: {
71
39
  start(options: {
72
- model: Uint8Array, vadModel: Uint8Array, language?: string, microphone?: number,
73
- singleUtterance?: boolean, interimResults?: boolean, wakeWord?: Uint8Array | string | string[], wakeThreshold?: number,
40
+ model: Uint8Array, vadModel: Uint8Array, lang?: string, microphone?: number,
41
+ continuous?: boolean, interimResults?: boolean, wakeWord?: Uint8Array | string | string[], wakeThreshold?: number,
74
42
  }): Promise<{ handle: number }>
75
- setResultCallback(handle: number, callback: (result: { text: string, final: boolean }) => void): void
43
+ setResultCallback(handle: number, callback: (result: { transcript: string, isFinal: boolean }) => void): void
76
44
  setSpeechStartCallback(handle: number, callback: () => void): void
77
45
  setSpeechEndCallback(handle: number, callback: () => void): void
78
46
  setWakeCallback(handle: number, callback: () => void): void
@@ -80,14 +48,6 @@ declare global {
80
48
  }
81
49
  }
82
50
 
83
- export interface MeasureTextOptions {
84
- fontFamily?: "sans" | "mono" | (string & {})
85
- fontSize?: number
86
- fontStyle?: "normal" | "italic"
87
- fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
88
- maxLines?: number
89
- }
90
-
91
51
  type Children = SolidJSX.Element
92
52
 
93
53
  interface FlexboxProps {
@@ -136,6 +96,7 @@ export interface LayoutProps extends FlexboxProps, GridProps {
136
96
  minHeight?: Dimension
137
97
  maxWidth?: Dimension
138
98
  maxHeight?: Dimension
99
+ aspectRatio?: number | (string & {})
139
100
 
140
101
  padding?: Dimension
141
102
  paddingTop?: Dimension
@@ -154,11 +115,12 @@ export interface LayoutProps extends FlexboxProps, GridProps {
154
115
  overflowY?: "visible" | "clip" | "hidden" | "scroll"
155
116
  }
156
117
 
157
- // Colors are CSS color strings, parsed to a packed u32 by parseColorToU32.
118
+ /** Colors are CSS color strings, parsed to a packed u32 by `parseColor`. */
158
119
  export type Color = string
159
120
 
160
121
  export interface PaintProps {
161
- color?: Color
122
+ // A solid color, or a gradient from createLinearGradient/createRadialGradient.
123
+ color?: Color | Gradient
162
124
  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"
163
125
  drawStyle?: "fill" | "stroke" | "stroke-and-fill"
164
126
  strokeCap?: "butt" | "round" | "square"
@@ -296,7 +258,15 @@ export interface LineProps extends PaintProps, PointerProps {
296
258
 
297
259
  export interface PathProps extends Position, PaintProps, PointerProps {
298
260
  d?: string
299
- fillRule?: "nonZero" | "evenOdd"
261
+ fillRule?: "nonzero" | "evenodd"
262
+ }
263
+
264
+ export interface SvgProps extends Position, PointerProps {
265
+ // A whole SVG document as a string (an imported asset, a fetched string, or a
266
+ // template literal). Parsed and rendered as one unit; takes no JSX children.
267
+ src?: string
268
+ // Drives currentColor in the document. Explicit fills/strokes still win.
269
+ color?: Color
300
270
  }
301
271
 
302
272
  export interface TextProps extends PaintProps, PointerProps {