@spooky-sync/client-solid2 0.0.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +81 -0
  2. package/QUICK_START.md +129 -0
  3. package/README.md +19 -0
  4. package/dist/index.cjs +1290 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +670 -0
  7. package/dist/index.d.cts.map +1 -0
  8. package/dist/index.d.ts +670 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +1266 -0
  11. package/dist/index.js.map +1 -0
  12. package/package.json +63 -0
  13. package/skills/sp00ky-solid2/SKILL.md +71 -0
  14. package/src/index.ts +410 -0
  15. package/src/lib/Blurhash.ts +64 -0
  16. package/src/lib/BucketImage.ts +170 -0
  17. package/src/lib/Sp00kyProvider.ts +104 -0
  18. package/src/lib/__tests__/conflate.test.ts +120 -0
  19. package/src/lib/__tests__/create-query.test.ts +397 -0
  20. package/src/lib/__tests__/rc-semantics.test.ts +393 -0
  21. package/src/lib/__tests__/use-sync-activity.test.ts +104 -0
  22. package/src/lib/conflate.ts +74 -0
  23. package/src/lib/context.ts +28 -0
  24. package/src/lib/create-preload.ts +116 -0
  25. package/src/lib/create-query.ts +388 -0
  26. package/src/lib/create-submission.ts +57 -0
  27. package/src/lib/from-subscription.ts +32 -0
  28. package/src/lib/merge-rows.test.ts +92 -0
  29. package/src/lib/merge-rows.ts +72 -0
  30. package/src/lib/models.ts +8 -0
  31. package/src/lib/use-app-release.ts +89 -0
  32. package/src/lib/use-blurhash.ts +82 -0
  33. package/src/lib/use-bucket-image.ts +108 -0
  34. package/src/lib/use-crdt-field.ts +57 -0
  35. package/src/lib/use-download-file.ts +181 -0
  36. package/src/lib/use-feature-flag.ts +43 -0
  37. package/src/lib/use-file-upload.ts +146 -0
  38. package/src/lib/use-storage-status.ts +44 -0
  39. package/src/lib/use-sync-activity.ts +99 -0
  40. package/src/lib/use-sync-status.ts +63 -0
  41. package/src/types/index.ts +83 -0
  42. package/tsconfig.json +27 -0
  43. package/tsdown.config.ts +20 -0
  44. package/vitest.config.ts +14 -0
package/src/index.ts ADDED
@@ -0,0 +1,410 @@
1
+ import type { SyncedDbConfig } from './types';
2
+ import {
3
+ Sp00kyClient,
4
+ type Sp00kyQueryResultPromise,
5
+ type AuthService,
6
+ type BucketHandle,
7
+ type UpdateOptions,
8
+ type RunOptions,
9
+ type SyncHealth,
10
+ type StorageHealth,
11
+ type PreloadOptions,
12
+ type PreloadRefresh,
13
+ } from '@spooky-sync/core';
14
+
15
+ import type {
16
+ GetTable,
17
+ QueryBuilder,
18
+ SchemaStructure,
19
+ TableModel,
20
+ TableNames,
21
+ QueryResult,
22
+ RelatedFieldsMap,
23
+ RelationshipFieldsFromSchema,
24
+ GetRelationship,
25
+ RelatedFieldMapEntry,
26
+ FinalQuery,
27
+ InnerQuery,
28
+ BackendNames,
29
+ BackendRoutes,
30
+ RoutePayload,
31
+ BucketNames,
32
+ BucketDefinitionSchema,
33
+ QueryModifier,
34
+ QueryModifierBuilder,
35
+ QueryInfo,
36
+ RelationshipsMetadata,
37
+ RelationshipDefinition,
38
+ InferRelatedModelFromMetadata,
39
+ GetCardinality,
40
+ } from '@spooky-sync/query-builder';
41
+
42
+ import { RecordId, Uuid, type Surreal } from 'surrealdb';
43
+ import { snapshot } from 'solid-js';
44
+ export { RecordId, Uuid };
45
+ export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
46
+ export { createQuery, useQuery, type CreateQueryResult, type QueryOptions } from './lib/create-query';
47
+ export { createPreload } from './lib/create-preload';
48
+ export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
49
+ export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
50
+ export {
51
+ useSyncActivity,
52
+ type UseSyncActivity,
53
+ type UseSyncActivityOptions,
54
+ } from './lib/use-sync-activity';
55
+ export type {
56
+ SyncHealth,
57
+ SyncHealthStatus,
58
+ SyncHealthConfig,
59
+ ConnectionState,
60
+ ReconnectConfig,
61
+ } from '@spooky-sync/core';
62
+ export { useStorageStatus, type UseStorageStatus } from './lib/use-storage-status';
63
+ export type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
64
+ export { useCrdtField } from './lib/use-crdt-field';
65
+ export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
66
+ export {
67
+ useAppRelease,
68
+ type UseAppRelease,
69
+ type UseAppReleaseOptions,
70
+ } from './lib/use-app-release';
71
+ export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
72
+ export {
73
+ useDownloadFile,
74
+ type UseDownloadFileOptions,
75
+ type UseDownloadFileResult,
76
+ } from './lib/use-download-file';
77
+ export { useBlurhash, type UseBlurhashResult } from './lib/use-blurhash';
78
+ export {
79
+ useBucketImage,
80
+ type UseBucketImageOptions,
81
+ type UseBucketImageResult,
82
+ } from './lib/use-bucket-image';
83
+ export { Blurhash, type BlurhashProps } from './lib/Blurhash';
84
+ export { BucketImage, type BucketImageProps } from './lib/BucketImage';
85
+ export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
86
+ export { useDb, usePendingMutations } from './lib/context';
87
+ export { createSubmission, type Submission } from './lib/create-submission';
88
+ export { conflate } from './lib/conflate';
89
+ export { fromSubscription } from './lib/from-subscription';
90
+
91
+ // Re-export query builder types for convenience
92
+ export type {
93
+ QueryModifier,
94
+ QueryModifierBuilder,
95
+ QueryInfo,
96
+ RelationshipsMetadata,
97
+ RelationshipDefinition,
98
+ InferRelatedModelFromMetadata,
99
+ GetCardinality,
100
+ GetTable,
101
+ TableModel,
102
+ TableNames,
103
+ QueryResult,
104
+ };
105
+
106
+ export type RelationshipField<
107
+ Schema extends SchemaStructure,
108
+ TableName extends TableNames<Schema>,
109
+ Field extends RelationshipFieldsFromSchema<Schema, TableName>,
110
+ > = GetRelationship<Schema, TableName, Field>;
111
+
112
+ export type RelatedFieldsTableScoped<
113
+ Schema extends SchemaStructure,
114
+ TableName extends TableNames<Schema>,
115
+ RelatedFields extends RelationshipFieldsFromSchema<Schema, TableName> =
116
+ RelationshipFieldsFromSchema<Schema, TableName>,
117
+ > = {
118
+ [K in RelatedFields]: {
119
+ to: RelationshipField<Schema, TableName, K>['to'];
120
+ relatedFields: RelatedFieldsMap;
121
+ cardinality: RelationshipField<Schema, TableName, K>['cardinality'];
122
+ };
123
+ };
124
+
125
+ export type InferModel<
126
+ Schema extends SchemaStructure,
127
+ TableName extends TableNames<Schema>,
128
+ RelatedFields extends RelatedFieldsTableScoped<Schema, TableName>,
129
+ > = QueryResult<Schema, TableName, RelatedFields, true>;
130
+
131
+ export type WithRelated<Field extends string, RelatedFields extends RelatedFieldsMap = {}> = {
132
+ [K in Field]: Omit<RelatedFieldMapEntry, 'relatedFields'> & {
133
+ relatedFields: RelatedFields;
134
+ };
135
+ };
136
+
137
+ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedFieldsMap = {}> = {
138
+ [K in Field]: {
139
+ to: Field;
140
+ relatedFields: RelatedFields;
141
+ cardinality: 'many';
142
+ };
143
+ };
144
+
145
+ /**
146
+ * SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration.
147
+ * Delegates all logic to the underlying sp00ky-ts instance.
148
+ *
149
+ * NOTE: keep in sync with packages/client-solid/src/index.ts (SyncedDb).
150
+ * Copied rather than shared so this package's dependency graph never pulls
151
+ * in solid-js 1.x; fold the two together once client-solid moves to Solid 2.
152
+ * Solid-2-only differences: `delete` accepts 'bound RecordId', and write
153
+ * payloads go through `snapshot()` (see `unproxy` below).
154
+ */
155
+
156
+ /**
157
+ * Solid 2 stores wrap every object read out of them (query rows, their nested
158
+ * arrays, `createStore` docs) in a Proxy. Those proxies cannot cross
159
+ * `postMessage` (the sqlite and shared-tabs workers): structuredClone throws
160
+ * DataCloneError. `snapshot()` returns the underlying plain value for store
161
+ * proxies and passes anything else through untouched.
162
+ */
163
+ function unproxy<T>(value: T): T {
164
+ if (value === null || typeof value !== 'object') return value;
165
+ return snapshot(value as object) as T;
166
+ }
167
+ export class SyncedDb<S extends SchemaStructure> {
168
+ private config: SyncedDbConfig<S>;
169
+ private sp00ky: Sp00kyClient<S> | null = null;
170
+ private _initialized = false;
171
+
172
+ constructor(config: SyncedDbConfig<S>) {
173
+ this.config = config;
174
+ }
175
+
176
+ public getSp00ky(): Sp00kyClient<S> {
177
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
178
+ return this.sp00ky;
179
+ }
180
+
181
+ /**
182
+ * Initialize the sp00ky-ts instance
183
+ */
184
+ async init(): Promise<void> {
185
+ if (this._initialized) return;
186
+ this.sp00ky = new Sp00kyClient<S>(this.config);
187
+ await this.sp00ky.init();
188
+ this._initialized = true;
189
+ }
190
+
191
+ /**
192
+ * Tear down the client: leaves the tabs broker, closes the local store and
193
+ * remote socket, and frees the wasm circuit. Without this a remounted provider
194
+ * (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
195
+ * resident because V8 cannot see how much wasm memory a dropped wrapper holds.
196
+ */
197
+ async close(): Promise<void> {
198
+ const instance = this.sp00ky;
199
+ this.sp00ky = null;
200
+ this._initialized = false;
201
+ if (instance) await instance.close();
202
+ }
203
+
204
+ /**
205
+ * Create a new record in the database
206
+ */
207
+ async create(id: string, payload: Record<string, unknown>): Promise<void> {
208
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
209
+ await this.sp00ky.create(id, unproxy(payload) as Record<string, unknown>);
210
+ }
211
+
212
+ /**
213
+ * Update an existing record in the database
214
+ */
215
+ async update<TName extends TableNames<S>>(
216
+ tableName: TName,
217
+ recordId: string,
218
+ payload: Partial<TableModel<GetTable<S, TName>>>,
219
+ options?: UpdateOptions
220
+ ): Promise<void> {
221
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
222
+ await this.sp00ky.update(
223
+ tableName as string,
224
+ recordId,
225
+ unproxy(payload) as Record<string, unknown>,
226
+ options
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Delete an existing record in the database
232
+ */
233
+ async delete<TName extends TableNames<S>>(
234
+ tableName: TName,
235
+ selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
236
+ ): Promise<void> {
237
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
238
+ // Accept a `"table:id"` string OR a RecordId — live-query rows carry their
239
+ // `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
240
+ // directly. Build the canonical string from the raw id part (not
241
+ // `RecordId.toString()`, which escapes special chars) so it round-trips
242
+ // through the engine's `parseRecordIdString`. InnerQuery selectors are not
243
+ // supported yet. A RecordId read out of a Solid 2 query row is a store
244
+ // proxy that hides its prototype (no instanceof, no constructor.name, on
245
+ // rc.1), so unwrap it first; cross-package RecordId instances then match
246
+ // by constructor name ('bound RecordId' covers rc.0 proxies).
247
+ const raw = unproxy(selector);
248
+ const ctorName = (raw as any)?.constructor?.name;
249
+ const isRecordId =
250
+ raw instanceof RecordId || ctorName === 'RecordId' || ctorName === 'bound RecordId';
251
+ let id: string;
252
+ if (typeof raw === 'string') {
253
+ id = raw;
254
+ } else if (isRecordId) {
255
+ id = `${tableName as string}:${(raw as RecordId).id}`;
256
+ } else {
257
+ throw new Error('Only string ID or RecordId selectors are supported currently with core');
258
+ }
259
+ await this.sp00ky.delete(tableName as string, id);
260
+ }
261
+
262
+ /**
263
+ * Preload/prewarm a built query into the local cache without registering a
264
+ * live view. Fetches once and stores the rows (+ embedded related children)
265
+ * locally so a later `createQuery` for the same data paints instantly. Best-effort.
266
+ */
267
+ public async preload(
268
+ finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
269
+ options?: PreloadOptions
270
+ ): Promise<void> {
271
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
272
+ await this.sp00ky.preload(finalQuery, options);
273
+ }
274
+
275
+ /**
276
+ * Query data from the database
277
+ */
278
+ public query<TName extends TableNames<S>>(
279
+ table: TName
280
+ ): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
281
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
282
+ return this.sp00ky.query(table, {});
283
+ }
284
+
285
+ /**
286
+ * Run a backend operation
287
+ */
288
+ public async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
289
+ backend: B,
290
+ path: R,
291
+ payload: RoutePayload<S, B, R>,
292
+ options?: RunOptions
293
+ ): Promise<void> {
294
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
295
+ await this.sp00ky.run(backend, path, unproxy(payload), options);
296
+ }
297
+
298
+ /**
299
+ * Sign out, clear session and local storage
300
+ */
301
+ public async signOut(): Promise<void> {
302
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
303
+ await this.sp00ky.auth.signOut();
304
+ }
305
+
306
+ /**
307
+ * Execute a function with direct access to the remote database connection
308
+ */
309
+ public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
310
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
311
+ return await this.sp00ky.useRemote(fn);
312
+ }
313
+ /**
314
+ * Access the remote database service directly
315
+ */
316
+ get remote(): Sp00kyClient<S>['remoteClient'] {
317
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
318
+ return this.sp00ky.remoteClient;
319
+ }
320
+
321
+ /**
322
+ * Access the local database service directly
323
+ */
324
+ get local(): Sp00kyClient<S>['localClient'] {
325
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
326
+ return this.sp00ky.localClient;
327
+ }
328
+
329
+ /**
330
+ * Access the auth service
331
+ */
332
+ get auth(): AuthService<S> {
333
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
334
+ return this.sp00ky.auth;
335
+ }
336
+
337
+ get pendingMutationCount(): number {
338
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
339
+ return this.sp00ky.pendingMutationCount;
340
+ }
341
+
342
+ /** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
343
+ get liveRetryCount(): number {
344
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
345
+ return this.sp00ky.liveRetryCount;
346
+ }
347
+
348
+ subscribeToPendingMutations(cb: (count: number) => void): () => void {
349
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
350
+ return this.sp00ky.subscribeToPendingMutations(cb);
351
+ }
352
+
353
+ /** Queries mid-fetch right now. See {@link useSyncActivity}. */
354
+ get fetchingQueryCount(): number {
355
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
356
+ return this.sp00ky.fetchingQueryCount;
357
+ }
358
+
359
+ /**
360
+ * Observe how many queries are fetching from the server. Fires immediately
361
+ * and on every change. Prefer the `useSyncActivity` hook in components.
362
+ */
363
+ subscribeToFetchActivity(cb: (fetching: number) => void): () => void {
364
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
365
+ return this.sp00ky.subscribeToFetchActivity(cb);
366
+ }
367
+
368
+ /** Current sync-health snapshot. See {@link useSyncStatus}. */
369
+ get syncHealth(): SyncHealth {
370
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
371
+ return this.sp00ky.syncHealth;
372
+ }
373
+
374
+ /**
375
+ * Observe sync health. Fires immediately with the current status and again
376
+ * on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
377
+ * components; this is the imperative escape hatch.
378
+ */
379
+ subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
380
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
381
+ return this.sp00ky.subscribeToSyncHealth(cb);
382
+ }
383
+
384
+ /** Current local-store durability snapshot. See {@link useStorageStatus}. */
385
+ get storageHealth(): StorageHealth {
386
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
387
+ return this.sp00ky.storageHealth;
388
+ }
389
+
390
+ /**
391
+ * Observe local-store durability. Fires immediately with the current snapshot
392
+ * and again on change. Prefer the `useStorageStatus` hook in components; this
393
+ * is the imperative escape hatch.
394
+ */
395
+ subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
396
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
397
+ return this.sp00ky.subscribeToStorageHealth(cb);
398
+ }
399
+
400
+ bucket<B extends BucketNames<S>>(name: B): BucketHandle {
401
+ if (!this.sp00ky) throw new Error('SyncedDb not initialized');
402
+ return this.sp00ky.bucket(name);
403
+ }
404
+
405
+ getBucketConfig(name: string): BucketDefinitionSchema | undefined {
406
+ return this.config.schema.buckets?.find((b) => b.name === name);
407
+ }
408
+ }
409
+
410
+ export * from './types';
@@ -0,0 +1,64 @@
1
+ import { createEffect, type Element } from 'solid-js';
2
+ import { decodeBlurhash } from '@spooky-sync/core';
3
+
4
+ export interface BlurhashProps {
5
+ /** The blurhash string. Nullish paints nothing (transparent canvas). */
6
+ hash: string | null | undefined;
7
+ /** Decode resolution. 32x32 is plenty: blurhash carries at most 9x9 DCT
8
+ * components, the canvas is meant to be CSS-scaled to fill. */
9
+ width?: number;
10
+ height?: number;
11
+ /** Contrast punch, see the blurhash reference decoder. Defaults to 1. */
12
+ punch?: number;
13
+ class?: string;
14
+ style?: string;
15
+ }
16
+
17
+ /**
18
+ * A blurhash painted onto a canvas, once per hash change. Size the canvas via
19
+ * `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
20
+ * resolution stays tiny regardless of the displayed size.
21
+ */
22
+ export function Blurhash(props: BlurhashProps): Element {
23
+ if (typeof document === 'undefined') return null;
24
+ const canvas = document.createElement('canvas');
25
+
26
+ createEffect(
27
+ () => props.class ?? '',
28
+ (className) => {
29
+ canvas.className = className;
30
+ }
31
+ );
32
+ createEffect(
33
+ () => props.style ?? '',
34
+ (style) => {
35
+ canvas.style.cssText = style;
36
+ }
37
+ );
38
+
39
+ createEffect(
40
+ () => ({
41
+ width: props.width ?? 32,
42
+ height: props.height ?? 32,
43
+ hash: props.hash,
44
+ punch: props.punch ?? 1,
45
+ }),
46
+ ({ width, height, hash, punch }) => {
47
+ canvas.width = width;
48
+ canvas.height = height;
49
+ if (!hash) return;
50
+ try {
51
+ const pixels = decodeBlurhash(hash, width, height, punch);
52
+ const ctx = canvas.getContext('2d');
53
+ if (!ctx) return;
54
+ const imageData = ctx.createImageData(width, height);
55
+ imageData.data.set(pixels);
56
+ ctx.putImageData(imageData, 0, 0);
57
+ } catch {
58
+ // An invalid hash paints nothing; the layer below stays visible.
59
+ }
60
+ }
61
+ );
62
+
63
+ return canvas as unknown as Element;
64
+ }
@@ -0,0 +1,170 @@
1
+ import { createSignal, createEffect, onSettled, children, type Element } from 'solid-js';
2
+ import type { BucketNames, SchemaStructure } from '@spooky-sync/query-builder';
3
+ import { useBucketImage, type UseBucketImageOptions } from './use-bucket-image';
4
+ import { Blurhash } from './Blurhash';
5
+
6
+ export interface BucketImageProps {
7
+ /** Bucket name from the schema. */
8
+ bucket: string;
9
+ /** Path within the bucket. Nullish renders only the fallback layers. */
10
+ path: string | null | undefined;
11
+ alt?: string;
12
+ /** Classes for the container element (sizing/positioning). */
13
+ class?: string;
14
+ /** Classes for the inner `<img>` (the layout styles are inline). */
15
+ imgClass?: string;
16
+ /** `object-fit` for the image. Defaults to `cover`. */
17
+ fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
18
+ /**
19
+ * Bottom placeholder layer (your own plate/skeleton), shown until the image
20
+ * settles. The blurhash layer paints on top of it once the sidecar resolves.
21
+ */
22
+ fallback?: Element;
23
+ /** Crossfade duration in ms. Defaults to 300. */
24
+ transition?: number;
25
+ /** Crossfade easing. Defaults to an ease-out-expo curve. */
26
+ easing?: string;
27
+ /** Resolve the blurhash sidecar. Defaults to true. */
28
+ blurhash?: boolean;
29
+ /** Download tuning, forwarded to the underlying `useDownloadFile`. */
30
+ options?: UseBucketImageOptions;
31
+ }
32
+
33
+ const LAYER_STYLE = 'position:absolute;inset:0;width:100%;height:100%;';
34
+
35
+ /**
36
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
37
+ * plate, the automatically stored blurhash, and the real image, which stays
38
+ * transparent until the bitmap is DECODED and then crossfades over the
39
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
40
+ * prefers-reduced-motion (instant swap). The container is made
41
+ * `position: relative` unless your `class` positions it already.
42
+ *
43
+ * ```tsx
44
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
45
+ * fallback={<MyPlate />} alt="" />
46
+ * ```
47
+ */
48
+ export function BucketImage(props: BucketImageProps): Element {
49
+ if (typeof document === 'undefined') return null;
50
+
51
+ const image = useBucketImage(
52
+ props.bucket as BucketNames<SchemaStructure>,
53
+ () => props.path,
54
+ { ...props.options, blurhash: props.blurhash !== false }
55
+ );
56
+
57
+ const reducedMotion =
58
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
59
+
60
+ const root = document.createElement('div');
61
+ createEffect(
62
+ () => props.class ?? '',
63
+ (className) => {
64
+ root.className = className;
65
+ }
66
+ );
67
+ // Layers are absolutely positioned; give them an anchor without stomping on
68
+ // a caller class that already positions the container (inline would win).
69
+ onSettled(() => {
70
+ if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
71
+ });
72
+
73
+ const placeholder = document.createElement('div');
74
+ placeholder.style.cssText = LAYER_STYLE;
75
+ // Solid hands JSX props over as lazy thunks; `children` resolves them (and
76
+ // nested arrays/functions) to real nodes and keeps them alive reactively.
77
+ const fallbackHolder = document.createElement('div');
78
+ fallbackHolder.style.cssText = LAYER_STYLE;
79
+ placeholder.append(fallbackHolder);
80
+ const resolvedFallback = children(() => props.fallback);
81
+ createEffect(
82
+ () => resolvedFallback.toArray().filter((node) => node instanceof Node) as Node[],
83
+ (nodes) => {
84
+ fallbackHolder.replaceChildren(...nodes);
85
+ }
86
+ );
87
+ const hashCanvas = Blurhash({
88
+ get hash() {
89
+ return image.blurhash();
90
+ },
91
+ style: LAYER_STYLE,
92
+ });
93
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
94
+
95
+ const img = document.createElement('img');
96
+ img.decoding = 'async';
97
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
98
+ createEffect(
99
+ () => props.imgClass ?? '',
100
+ (className) => {
101
+ img.className = className;
102
+ }
103
+ );
104
+ createEffect(
105
+ () => props.fit ?? 'cover',
106
+ (fit) => {
107
+ img.style.objectFit = fit;
108
+ }
109
+ );
110
+ createEffect(
111
+ () => props.alt ?? '',
112
+ (alt) => {
113
+ img.alt = alt;
114
+ }
115
+ );
116
+ createEffect(
117
+ () =>
118
+ reducedMotion
119
+ ? 'none'
120
+ : `opacity ${props.transition ?? 300}ms ${props.easing ?? 'cubic-bezier(0.16, 1, 0.3, 1)'}`,
121
+ (transition) => {
122
+ img.style.transition = transition;
123
+ }
124
+ );
125
+ createEffect(
126
+ () => image.url(),
127
+ (url) => {
128
+ if (!url) {
129
+ img.removeAttribute('src');
130
+ return;
131
+ }
132
+ img.src = url;
133
+ image.gate(img);
134
+ }
135
+ );
136
+ createEffect(
137
+ () => image.ready(),
138
+ (ready) => {
139
+ img.style.opacity = ready ? '1' : '0';
140
+ }
141
+ );
142
+
143
+ // Placeholders leave the DOM once the fade is over (a shelf of covers should
144
+ // not composite three layers each forever) and come back when the path
145
+ // changes mid-life (`ready` re-arms via the hook). `settled` is written from
146
+ // the apply phase and a timer, outside any tracking scope.
147
+ const [settled, setSettled] = createSignal(false, { ownedWrite: true });
148
+ createEffect(
149
+ () => ({ ready: image.ready(), transition: props.transition ?? 300 }),
150
+ ({ ready, transition }) => {
151
+ if (!ready) {
152
+ setSettled(false);
153
+ return;
154
+ }
155
+ const wait = (reducedMotion ? 0 : transition) + 120;
156
+ const timer = setTimeout(() => setSettled(true), wait);
157
+ return () => clearTimeout(timer);
158
+ }
159
+ );
160
+ createEffect(
161
+ () => settled(),
162
+ (isSettled) => {
163
+ if (isSettled) placeholder.remove();
164
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
165
+ }
166
+ );
167
+
168
+ root.append(placeholder, img);
169
+ return root as unknown as Element;
170
+ }