@solidrt/core 0.0.64 → 0.0.66

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.
@@ -0,0 +1,333 @@
1
+ // @solidrt/core/textures - what a texture bake and a loader share, without
2
+ // the runtime: this entry imports nothing the runtime provides, so a bake
3
+ // script under bun and an extension's pure modules use it as it is
4
+ // (tests/textures.test.ts imports it on the bare flux binary and fails the
5
+ // moment a gui or sol: import creeps in). Any extension that bakes or loads textures
6
+ // builds on this, the first-party 3d one included.
7
+ //
8
+ // isKtx2 tells a compressed texture file from a PNG or JPEG.
9
+ //
10
+ // textureSettings reads how an app's textures are compressed at bake
11
+ // time: the `textures` group of the `solidrt` key in its package.json.
12
+ //
13
+ // "solidrt": {
14
+ // "textures": {
15
+ // "compress": true,
16
+ // "data": { "codec": "uastc", "quality": 0.9 },
17
+ // "files": [
18
+ // { "match": "assets/sponza/textures/lion_*.png", "quality": 0.95 },
19
+ // { "match": ["assets/ui/**", "assets/hero.glb#lut"], "compress": false }
20
+ // ]
21
+ // }
22
+ // }
23
+ //
24
+ // Three levels, the narrower one winning: the app (`compress`), a kind of
25
+ // texture (`compress`, `codec`, `quality`), and the entries of `files`,
26
+ // each naming files by glob pattern and setting the same three for them.
27
+ // Whatever is left out keeps what the level above says, and a null is a
28
+ // key left out. Anything unknown throws naming the key.
29
+ //
30
+ // A pattern is matched against a file's path from the project root,
31
+ // written with "/". A texture that is a file of its own is that file; one
32
+ // embedded in another file (an image inside a .glb) is that file, and
33
+ // "#name" after the pattern picks it by its name there. The pattern
34
+ // language is matchesGlob's (flux:path), which the caller passes in, as
35
+ // it passes the scan (glob, flux:fs): this entry stays without a runtime.
36
+
37
+ // The first twelve bytes of every KTX2 file.
38
+ const KTX2_MAGIC = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]
39
+
40
+ /** Whether `bytes` starts as a KTX2 file, the compressed texture
41
+ * `transcodeTexture` takes: what tells one from a PNG or JPEG. */
42
+ export function isKtx2(bytes: Uint8Array): boolean {
43
+ return bytes.length >= KTX2_MAGIC.length && KTX2_MAGIC.every((b, i) => bytes[i] === b)
44
+ }
45
+
46
+ /** The codecs a texture is encoded with: `encodeTexture`'s (flux:image),
47
+ * named here so that this entry needs no runtime type. "etc1s" is the
48
+ * small one, "uastc" the accurate one. */
49
+ export type TextureCodec = "etc1s" | "uastc"
50
+
51
+ /**
52
+ * What the texels of a texture ARE, which decides how it may be
53
+ * compressed. Not what draws it: an extension maps its own uses (a
54
+ * material's slots, a sprite sheet, a terrain's layers) onto these.
55
+ *
56
+ * - `color`: color as the eye sees it, sRGB-encoded. A photo, a painted
57
+ * surface, a sprite, a tile set.
58
+ * - `normal`: directions, unit vectors packed into the channels. An error
59
+ * tilts the lighting computed from them.
60
+ * - `data`: any other linear values that weigh a computation and tolerate
61
+ * a small error: a mask, a roughness, a height for parallax.
62
+ *
63
+ * Values that must come back EXACT are none of these and are not block
64
+ * compressed at all, at any setting: a lookup table, a distance field, an
65
+ * index map. The same goes for sharp art and text. An entry of `files`
66
+ * with `compress: false` keeps such a texture as its file.
67
+ */
68
+ export type TextureKind = "color" | "normal" | "data"
69
+
70
+ export type TextureSetting = {
71
+ /** Whether the texture is compressed at all. False keeps its file as it
72
+ * is, and `codec` and `quality` say nothing then. */
73
+ compress: boolean
74
+ codec: TextureCodec
75
+ /** 0..1, higher is larger and closer to the source. */
76
+ quality: number
77
+ }
78
+
79
+ /** One entry of `files`: the files it names and what it sets for them. */
80
+ export type TextureFileRule = {
81
+ /** The patterns, any of which names a file of this entry. */
82
+ match: string[]
83
+ compress?: boolean
84
+ codec?: TextureCodec
85
+ quality?: number
86
+ }
87
+
88
+ export type TextureSettings = { readonly [kind in TextureKind]: Readonly<TextureSetting> } & {
89
+ /** The entries in the order written; where several name one file, a
90
+ * later entry's fields win over an earlier one's. */
91
+ readonly files: readonly Readonly<TextureFileRule>[]
92
+ }
93
+
94
+ /** What a texture is to the settings: the file holding it, as its path
95
+ * from the project root, and for a texture embedded in that file its name
96
+ * there. */
97
+ export type TextureSource = { file: string; name?: string }
98
+
99
+ /** Whether a path matches a glob pattern: `matchesGlob` of flux:path. */
100
+ export type GlobMatcher = (path: string, pattern: string) => boolean
101
+
102
+ /** The files a glob pattern matches, from the project root: `glob` of
103
+ * flux:fs. */
104
+ export type GlobScan = (pattern: string) => Promise<string[]>
105
+
106
+ const KINDS: TextureKind[] = ["color", "normal", "data"]
107
+ const CODECS: TextureCodec[] = ["etc1s", "uastc"]
108
+
109
+ // What parts a pattern that picks an embedded texture: the file before it,
110
+ // the name after it.
111
+ const NAME_MARK = "#"
112
+
113
+ // Textures are compressed unless the app says otherwise: a quarter of the
114
+ // GPU memory and a faster load are what an app that says nothing wants.
115
+ const DEFAULT_COMPRESS = true
116
+
117
+ // The defaults, by what each kind tolerates. Color takes the small codec
118
+ // (ETC1S moves little across its scale, so it sits high). Directions show
119
+ // their errors as blotches in the lighting, and data is often several
120
+ // unrelated values packed into the channels of one image, which the small
121
+ // codec handles badly: both take the accurate one, at the quality below
122
+ // which it gives up detail for almost no bytes. This is what the Khronos
123
+ // KTX guide and glTF-Transform recommend. The accurate codec is the larger
124
+ // download; an app that prefers the smaller one says so in its settings.
125
+ const SMALL_CODEC_QUALITY = 0.75
126
+ const ACCURATE_CODEC_QUALITY = 0.9
127
+
128
+ function frozen(settings: { [kind in TextureKind]: TextureSetting }, files: TextureFileRule[]): TextureSettings {
129
+ for (let kind of KINDS) Object.freeze(settings[kind])
130
+ for (let rule of files) {
131
+ Object.freeze(rule.match)
132
+ Object.freeze(rule)
133
+ }
134
+ return Object.freeze({ ...settings, files: Object.freeze(files) })
135
+ }
136
+
137
+ /** The settings of an app that declares none. Frozen, like every
138
+ * settings object this entry hands out. */
139
+ export const DEFAULT_TEXTURE_SETTINGS: TextureSettings = frozen(
140
+ {
141
+ color: { compress: DEFAULT_COMPRESS, codec: "etc1s", quality: SMALL_CODEC_QUALITY },
142
+ normal: { compress: DEFAULT_COMPRESS, codec: "uastc", quality: ACCURATE_CODEC_QUALITY },
143
+ data: { compress: DEFAULT_COMPRESS, codec: "uastc", quality: ACCURATE_CODEC_QUALITY },
144
+ },
145
+ [],
146
+ )
147
+
148
+ function isRecord(value: unknown): value is Record<string, unknown> {
149
+ return typeof value === "object" && value !== null && !Array.isArray(value)
150
+ }
151
+
152
+ // The fields a kind and an entry share, read into `into`. `key` names the
153
+ // group in a message; `others` are the group's further fields, which the
154
+ // caller reads.
155
+ function readFields(group: Record<string, unknown>, key: string, into: Partial<TextureSetting>, others: string[]) {
156
+ for (let [field, given] of Object.entries(group)) {
157
+ if (given === null || others.includes(field)) continue
158
+ let name = `${key}.${field}`
159
+ if (field === "compress") {
160
+ if (typeof given !== "boolean") throw new Error(`${name}: ${JSON.stringify(given)} is not true or false`)
161
+ into.compress = given
162
+ } else if (field === "codec") {
163
+ if (!CODECS.includes(given as TextureCodec)) throw new Error(`${name}: unknown codec ${JSON.stringify(given)} (expected "etc1s" or "uastc")`)
164
+ into.codec = given as TextureCodec
165
+ } else if (field === "quality") {
166
+ if (typeof given !== "number" || !(given >= 0 && given <= 1)) throw new Error(`${name}: ${JSON.stringify(given)} is not a number in 0..1`)
167
+ into.quality = given
168
+ } else {
169
+ throw new Error(`${name}: unknown field (expected ${[...others, "compress", "codec", "quality"].join(", ")})`)
170
+ }
171
+ }
172
+ }
173
+
174
+ // A pattern as its file part and, after the mark, its name part. A mark
175
+ // inside a set ("[#]") is a character of the file's name.
176
+ function splitPattern(pattern: string): { file: string; name?: string } {
177
+ for (let i = 0; i < pattern.length; i++) {
178
+ let c = pattern[i]
179
+ if (c === "[") {
180
+ // A "]" right after the opening, or after the negation, is a member.
181
+ let first = pattern[i + 1] === "!" ? i + 2 : i + 1
182
+ let close = pattern.indexOf("]", first + 1)
183
+ if (close < 0) break
184
+ i = close
185
+ } else if (c === NAME_MARK) {
186
+ return { file: pattern.slice(0, i), name: pattern.slice(i + 1) }
187
+ }
188
+ }
189
+ return { file: pattern }
190
+ }
191
+
192
+ function readRule(given: unknown, key: string): TextureFileRule {
193
+ if (!isRecord(given)) throw new Error(`${key} must be an object with match and what it sets`)
194
+ let match = typeof given.match === "string" ? [given.match] : given.match
195
+ if (!Array.isArray(match) || match.length === 0 || !match.every((pattern) => typeof pattern === "string" && pattern !== "")) {
196
+ throw new Error(`${key}.match must be a pattern or a list of patterns`)
197
+ }
198
+ for (let pattern of match as string[]) {
199
+ let { file, name } = splitPattern(pattern)
200
+ if (file === "" || name === "") {
201
+ throw new Error(`${key}.match: ${JSON.stringify(pattern)} must name a file before "${NAME_MARK}" and a texture after it`)
202
+ }
203
+ }
204
+ let rule: TextureFileRule = { match: match as string[] }
205
+ readFields(given, key, rule, ["match"])
206
+ if (rule.compress === undefined && rule.codec === undefined && rule.quality === undefined) {
207
+ throw new Error(`${key} sets nothing (expected compress, codec or quality)`)
208
+ }
209
+ if (rule.compress === false && (rule.codec !== undefined || rule.quality !== undefined)) {
210
+ throw new Error(`${key}: compress is false, which leaves codec and quality nothing to apply to`)
211
+ }
212
+ return rule
213
+ }
214
+
215
+ /**
216
+ * The texture settings of a parsed package.json: its `solidrt.textures`
217
+ * group over the defaults. A package.json without the group (or none at
218
+ * all: pass undefined) gives the defaults.
219
+ *
220
+ * `run.compress` stands in for the app's own `compress` for this one run
221
+ * (a bake's `--compress` and `--no-compress`); what a kind or an entry
222
+ * says about itself still holds.
223
+ *
224
+ * Throws on an unknown key or field, a codec that does not exist, a
225
+ * quality outside 0..1, an entry without a pattern or that sets nothing.
226
+ * The patterns themselves are read by the matcher: `unmatchedTextureFiles`
227
+ * runs every one of them, so a bake that starts with it meets a malformed
228
+ * pattern before it encodes anything.
229
+ */
230
+ export function textureSettings(packageJson: unknown, run: { compress?: boolean } = {}): TextureSettings {
231
+ let solidrt = isRecord(packageJson) ? packageJson.solidrt : undefined
232
+ let group = isRecord(solidrt) ? (solidrt.textures ?? undefined) : undefined
233
+ if (group !== undefined && !isRecord(group)) {
234
+ throw new Error("solidrt.textures must be an object (compress, files, and the texture kinds color, normal, data)")
235
+ }
236
+ let given: Record<string, unknown> = group ?? {}
237
+
238
+ let app: Partial<TextureSetting> = {}
239
+ if (given.compress !== undefined && given.compress !== null) {
240
+ if (typeof given.compress !== "boolean") throw new Error(`solidrt.textures.compress: ${JSON.stringify(given.compress)} is not true or false`)
241
+ app.compress = given.compress
242
+ }
243
+ let compress = run.compress ?? app.compress ?? DEFAULT_COMPRESS
244
+
245
+ let kinds = {} as { [kind in TextureKind]: TextureSetting }
246
+ for (let kind of KINDS) kinds[kind] = { ...DEFAULT_TEXTURE_SETTINGS[kind], compress }
247
+ let files: TextureFileRule[] = []
248
+ for (let [key, value] of Object.entries(given)) {
249
+ if (key === "compress" || value === null) continue
250
+ let name = `solidrt.textures.${key}`
251
+ if (key === "files") {
252
+ if (!Array.isArray(value)) throw new Error(`${name} must be a list of entries, each with match and what it sets`)
253
+ files = value.map((rule, i) => readRule(rule, `${name}[${i}]`))
254
+ } else if (KINDS.includes(key as TextureKind)) {
255
+ if (!isRecord(value)) throw new Error(`${name} must be an object with compress, codec and quality`)
256
+ readFields(value, name, kinds[key as TextureKind], [])
257
+ } else {
258
+ throw new Error(`${name}: unknown key (expected compress, files, or a texture kind: color, normal, data)`)
259
+ }
260
+ }
261
+ return frozen(kinds, files)
262
+ }
263
+
264
+ // Whether a pattern names the source: its file part matches the file, and
265
+ // its name part, when it has one, the name of a texture embedded there.
266
+ function patternNames(pattern: string, source: TextureSource, matches: GlobMatcher): boolean {
267
+ let { file, name } = splitPattern(pattern)
268
+ if (!matches(source.file, file)) return false
269
+ return name === undefined || (source.name !== undefined && matches(source.name, name))
270
+ }
271
+
272
+ /**
273
+ * How one texture is compressed: what its kind says, under the fields of
274
+ * every entry of `files` that names it, in the order written. A pattern
275
+ * without a name part names a file and everything embedded in it; one
276
+ * with a name part names the embedded textures of that name, never a
277
+ * texture that is a file of its own.
278
+ *
279
+ * @param matches `matchesGlob` of flux:path.
280
+ */
281
+ export function textureSettingFor(settings: TextureSettings, kind: TextureKind, source: TextureSource, matches: GlobMatcher): TextureSetting {
282
+ let setting: TextureSetting = { ...settings[kind] }
283
+ for (let rule of settings.files) {
284
+ if (!rule.match.some((pattern) => patternNames(pattern, source, matches))) continue
285
+ if (rule.compress !== undefined) setting.compress = rule.compress
286
+ if (rule.codec !== undefined) setting.codec = rule.codec
287
+ if (rule.quality !== undefined) setting.quality = rule.quality
288
+ }
289
+ return setting
290
+ }
291
+
292
+ /**
293
+ * The patterns of `files`, as written, whose file matches nothing in the
294
+ * project: a misspelled pattern, which would otherwise do nothing and say
295
+ * nothing. The settings are the app's and a bake sees one file of it, so
296
+ * the question goes to the project's files, not to what is being baked.
297
+ * A bake calls this first and fails on what it returns.
298
+ *
299
+ * @param scan `(pattern) => glob(pattern, { cwd })` of flux:fs, with the
300
+ * project root as cwd. It throws on a malformed pattern.
301
+ */
302
+ export async function unmatchedTextureFiles(settings: TextureSettings, scan: GlobScan): Promise<string[]> {
303
+ let unmatched: string[] = []
304
+ for (let rule of settings.files) {
305
+ for (let pattern of rule.match) {
306
+ let found = await scan(splitPattern(pattern).file)
307
+ if (found.length === 0) unmatched.push(pattern)
308
+ }
309
+ }
310
+ return unmatched
311
+ }
312
+
313
+ /**
314
+ * The patterns of `files`, as written, that pick an embedded texture of
315
+ * `file` by a name none of its textures has. What `unmatchedTextureFiles`
316
+ * cannot see, since it needs the file opened: the bake of that file calls
317
+ * this with the names it found and fails on what it returns.
318
+ *
319
+ * @param file The file being baked, as its path from the project root.
320
+ * @param names The names of the textures embedded in it.
321
+ * @param matches `matchesGlob` of flux:path.
322
+ */
323
+ export function unmatchedTextureNames(settings: TextureSettings, file: string, names: string[], matches: GlobMatcher): string[] {
324
+ let unmatched: string[] = []
325
+ for (let rule of settings.files) {
326
+ for (let pattern of rule.match) {
327
+ let split = splitPattern(pattern)
328
+ if (split.name === undefined || !matches(file, split.file)) continue
329
+ if (!names.some((name) => matches(name, split.name!))) unmatched.push(pattern)
330
+ }
331
+ }
332
+ return unmatched
333
+ }
package/src/transform.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { onSettled } from "@solidjs/signals"
2
- import { on } from "srt:events"
2
+ import { on } from "sol:events"
3
3
  import type { PointerEvent } from "./types"
4
4
  import { arena, type ArenaOwner } from "./arena"
5
5
  import { createVelocityTracker, flingVelocity } from "./velocity"
@@ -21,8 +21,8 @@ const SPAN_SMOOTH = 0.28
21
21
  // thresholds is the hysteresis that keeps the gate from chattering.
22
22
  // All three step-based constants assume a ~65Hz frame rate (the captured
23
23
  // tablet); a 120Hz panel halves per-step deltas. If that ever bites,
24
- // convert the thresholds to px/s with performance.now() read at the
25
- // pointerFrame terminator.
24
+ // convert the thresholds to px/s with the pointerFrame terminator's
25
+ // timeStamp.
26
26
  const QUIET = 0.3
27
27
  const ENGAGE = 0.6
28
28
  const QUIET_SMOOTH = 0.19
@@ -147,6 +147,8 @@ export function createTransform(options: TransformOptions) {
147
147
  let spanRate = 0
148
148
  // Motion arrived this frame; measured and emitted at the terminator.
149
149
  let dirty = false
150
+ // A move of the current batch was predicted (see flush).
151
+ let predicted = false
150
152
  // The anchor must be retaken at the terminator (activation and set-change
151
153
  // rebases): mid-batch the pointer map is mixed-age, so anchoring
152
154
  // immediately would bake a jolt into the next delta. A rebase frame
@@ -208,8 +210,14 @@ export function createTransform(options: TransformOptions) {
208
210
  let owner: ArenaOwner = { cancel }
209
211
 
210
212
  // The per-frame measure point: runs at the pointerFrame terminator, when
211
- // every tracked pointer's position is the same age.
212
- let flush = () => {
213
+ // every tracked pointer's move of the batch has dispatched: the
214
+ // terminator's timeStamp (the latest of theirs) is the time of what is
215
+ // measured here.
216
+ let flush = (frame: { timeStamp: number }) => {
217
+ // The measure follows a predicted position; the velocity history takes
218
+ // real ones only.
219
+ let real = !predicted
220
+ predicted = false
213
221
  if (!active) return
214
222
  if (rebase) {
215
223
  // Anchor from same-age positions; emits nothing - an activation or
@@ -219,7 +227,7 @@ export function createTransform(options: TransformOptions) {
219
227
  let prev = ref
220
228
  ref = measure()
221
229
  if (prev) tracker.shift(ref.px - prev.px, ref.py - prev.py)
222
- tracker.push(ref.px, ref.py)
230
+ if (real) tracker.push(ref.px, ref.py, frame.timeStamp)
223
231
  spanBase = ref.span
224
232
  smoothSpan = ref.span
225
233
  rebase = false
@@ -229,7 +237,7 @@ export function createTransform(options: TransformOptions) {
229
237
  if (!dirty || !ref) return
230
238
  dirty = false
231
239
  let m = measure()
232
- tracker.push(m.px, m.py)
240
+ if (real) tracker.push(m.px, m.py, frame.timeStamp)
233
241
  let prevSpan = smoothSpan
234
242
  smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
235
243
  spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
@@ -285,6 +293,7 @@ export function createTransform(options: TransformOptions) {
285
293
  onPointerMove: (e: PointerEvent) => {
286
294
  if (!pointers.has(e.pointerId) || !ref) return
287
295
  pointers.set(e.pointerId, at(e))
296
+ if (e.predicted) predicted = true
288
297
  if (!active) {
289
298
  // Arming runs per event: the slop test tolerates a mixed-age
290
299
  // measure (8px against <=1 frame of staleness), and the arena
@@ -328,7 +337,7 @@ export function createTransform(options: TransformOptions) {
328
337
  if (pointers.size === 0) {
329
338
  // The lift's own position is not measured (no terminator
330
339
  // follows it); the fit over the delivered frames is the speed.
331
- let velocity = flingVelocity(tracker.velocity())
340
+ let velocity = flingVelocity(tracker.velocity(e.timeStamp))
332
341
  active = false
333
342
  ref = null
334
343
  pinch = false
package/src/types.d.ts CHANGED
@@ -9,14 +9,14 @@ import type { Element } from "solid-js"
9
9
 
10
10
  export type { TextInputHints }
11
11
 
12
- // The "srt:*" lattice runner modules are declared in ./runtime-modules.d.ts
12
+ // The "sol:*" lattice runner modules are declared in ./runtime-modules.d.ts
13
13
  // (referenced above) - ambient `declare module` only reaches consumers from a
14
14
  // non-module declaration file, and this file is a module.
15
15
 
16
16
  declare global {
17
17
  interface ImportMeta {
18
18
  /**
19
- * Build-mode constants, substituted textually by the srt bundler (Vite
19
+ * Build-mode constants, substituted textually by the sol bundler (Vite
20
20
  * vocabulary). `DEV` is true in dev bundles and false in production
21
21
  * bundles, where the substituted constant lets the minifier fold
22
22
  * dev-only code away entirely.
@@ -280,6 +280,33 @@ export interface TransformProps {
280
280
  // the DOM MouseEvent). pointerType distinguishes mouse from touch; button is the
281
281
  // pressed button on down/up (0 = primary); the modifier flags mirror the DOM.
282
282
  export interface PointerEvent {
283
+ /**
284
+ * When the event happened, in milliseconds. Every input event carries one,
285
+ * on one clock, so two stamps subtract to the time between two events: a
286
+ * tap's length, the gap of a double tap, the age of the last move at a
287
+ * lift. It is the input's own time, not its delivery's: a move carries the
288
+ * time the pointer was at the position it reports, however late the
289
+ * frame that delivers it, and a down, an up, a wheel or a key the time
290
+ * the platform recorded for it. A `predicted` move runs ahead of its
291
+ * pointer, so the real move after it can carry an earlier time. Use it
292
+ * where you would reach for `performance.now()` in a handler: that one
293
+ * measures when the handler ran, not when the input happened, and it does
294
+ * not follow the dev clock, `sol render` or a test. The origin is
295
+ * arbitrary (compare stamps with each other, not with `performance.now()`,
296
+ * a timer or the `onFrame` tick).
297
+ */
298
+ timeStamp: number
299
+ /**
300
+ * True for a move whose position the pointer was never reported at. Touch
301
+ * is delivered in batches, and a batch can miss the frame it belongs to;
302
+ * the runtime then bridges the gap with one predicted step, so a drag
303
+ * does not stall and jump. Draw with it, as with any move. Do not measure
304
+ * with it: leave it out of a velocity or a path. If the finger had in
305
+ * fact stopped, the next move reports where it really is, with the time
306
+ * it got there, which is earlier than the predicted move's. False on
307
+ * every other event.
308
+ */
309
+ predicted: boolean
283
310
  clientX: number
284
311
  clientY: number
285
312
  /**
@@ -340,6 +367,9 @@ export interface WheelEvent extends PointerEvent {
340
367
  // to the window root alone. <window onKeyDown> is therefore the app-global
341
368
  // shortcut point.
342
369
  export interface KeyEvent {
370
+ /** When the key event happened, in milliseconds, on the clock of
371
+ * `PointerEvent.timeStamp`. */
372
+ timeStamp: number
343
373
  key: string
344
374
  code: string
345
375
  repeat: boolean
@@ -363,7 +393,19 @@ export interface TextEvent {
363
393
  text: string
364
394
  }
365
395
 
366
- export interface PointerProps {
396
+ export interface NodeProps {
397
+ /**
398
+ * A stable name for the node, for whoever has to find it again with
399
+ * nothing visible to find it by: a test (`find({ label })` from
400
+ * `@solidrt/test`) or a dev tool (the tree record carries it and the
401
+ * tree query matches it). Never affects layout, paint or input. Prefer
402
+ * finding a node by its text where it has one: such a test breaks when
403
+ * what the user sees breaks.
404
+ */
405
+ label?: string
406
+ }
407
+
408
+ export interface PointerProps extends NodeProps {
367
409
  onPointerDown?: (event: PointerEvent) => void
368
410
  onPointerUp?: (event: PointerEvent) => void
369
411
  onPointerMove?: (event: PointerEvent) => void