solid-drift 0.26.0 → 0.28.0
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/README.md +46 -0
- package/dist/hardware.d.ts +207 -0
- package/dist/hardware.js +631 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/optimistic.d.ts +44 -0
- package/dist/optimistic.js +76 -0
- package/dist/voice.d.ts +1 -1
- package/dist/voice.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1571,6 +1571,52 @@ const prompt = createPrompt({
|
|
|
1571
1571
|
- `createThinking(options?)` cycles `"Thinking"`, `"Thinking."`, ... through `phrases` at `interval` ms: `{ text, running, start, stop }`.
|
|
1572
1572
|
- `createPrompt(options?)` is the voice-enabled input: `{ value, setValue, listening, interim, supported, toggleMic, submit, clear }`. Mic finals are appended to the value as they arrive; `submit()` fires `onSubmit` and clears by default. Everything is SSR-safe: unsupported primitives report `supported: false` and their actions no-op on the server.
|
|
1573
1573
|
|
|
1574
|
+
### Mobile hardware
|
|
1575
|
+
|
|
1576
|
+
```tsx
|
|
1577
|
+
import { createBattery, createShare, createScanline } from "solid-drift";
|
|
1578
|
+
|
|
1579
|
+
const battery = createBattery();
|
|
1580
|
+
const share = createShare();
|
|
1581
|
+
const scanline = createScanline({ duration: 1800 });
|
|
1582
|
+
|
|
1583
|
+
scanline.start();
|
|
1584
|
+
// in your scanner viewfinder:
|
|
1585
|
+
// <div class="line" style={{ top: `${scanline.progress() * 100}%` }} />
|
|
1586
|
+
|
|
1587
|
+
<p>Battery: {Math.round(battery.level() * 100)}% {battery.charging() ? "(charging)" : ""}</p>
|
|
1588
|
+
<button onClick={() => share.share({ title: "solid-drift", url: location.href })}>Share</button>
|
|
1589
|
+
```
|
|
1590
|
+
|
|
1591
|
+
- `createBattery()` wraps `navigator.getBattery()`: `{ supported, charging, level, chargingTime, dischargingTime, error }`. Level is 0..1; times are seconds (`Infinity` when unknown). Listeners detach on cleanup.
|
|
1592
|
+
- `createNetwork()` tracks `navigator.onLine` plus the Network Information API: `{ online, effectiveType, downlink, rtt, saveData, supported }`. Updates on `online`/`offline` events and the connection `change` event.
|
|
1593
|
+
- `createWakeLock()` keeps the screen awake: `{ supported, active, error, request, release }`. Re-acquires automatically when the tab becomes visible again if the lock was still wanted.
|
|
1594
|
+
- `createContactPick()` wraps the Contact Picker API: `{ supported, contacts, error, pick }`. `pick({ multiple })` resolves with normalized `{ name, tel, email }` arrays, or an empty array when the user cancels.
|
|
1595
|
+
- `createOTP()` wraps the WebOTP API: `{ supported, code, error, wait, abort }`. `wait({ transport })` resolves with the SMS code (or `null` when aborted). Requires a secure origin and an origin-bound SMS format.
|
|
1596
|
+
- `createShare()` wraps the Web Share API: `{ supported, canShare, error, share }`. `share({ title, text, url, files })` opens the native sheet; user dismissal is not an error.
|
|
1597
|
+
- `createNFC()` wraps Web NFC (Chrome on Android, secure context, needs a user gesture): `{ supported, scanning, message, error, scan, write, abort }`. Scanned tags land in `message()` with decoded `text`/`url` records plus `serialNumber`; `write()` takes a string or `{ records }`.
|
|
1598
|
+
- `createTorch()` drives the camera flashlight: `{ supported, on, error, attach, set, toggle }`. `attach(trackOrStream)` checks the `torch` capability, then `set(true/false)` applies it via `applyConstraints`.
|
|
1599
|
+
- `createGyro()` wraps `deviceorientation`: `{ supported, needsPermission, alpha, beta, gamma, absolute, listening, error, requestPermission, start, stop }`. On iOS, call `requestPermission()` from a tap handler before `start()`; `start()` also requests it if needed.
|
|
1600
|
+
- `createShake(options?)` detects shake gestures from `devicemotion`: `{ supported, needsPermission, listening, shakes, error, requestPermission, start, stop }`. A shake counts when the acceleration delta exceeds `threshold` (default 15 m/s^2), rate-limited by `cooldown` (default 800ms); `onShake` fires per shake.
|
|
1601
|
+
- `createScanline(options?)` is the animated line of a QR/barcode viewfinder: `{ progress, running, start, stop }`. `progress()` sweeps 0..1 on the shared clock (`direction: "down" | "up" | "alternate"`); bind it to the line's position. Under reduced motion it freezes mid-frame. Every primitive is SSR-safe: server renders get `supported: false` and safe no-op actions.
|
|
1602
|
+
|
|
1603
|
+
### Optimistic updates
|
|
1604
|
+
|
|
1605
|
+
```tsx
|
|
1606
|
+
import { createOptimistic } from "solid-drift";
|
|
1607
|
+
|
|
1608
|
+
const feed = createOptimistic<Bid[], Bid>([], (bids, bid) =>
|
|
1609
|
+
bids.some((b) => b.id === bid.id) ? bids : [...bids, bid],
|
|
1610
|
+
);
|
|
1611
|
+
|
|
1612
|
+
// in an event handler:
|
|
1613
|
+
await feed.commit(bid, (b) => sendBidTx(b)).catch(() => {
|
|
1614
|
+
// already rolled back; read feed.error() for a toast
|
|
1615
|
+
});
|
|
1616
|
+
```
|
|
1617
|
+
|
|
1618
|
+
`createOptimistic(initial, apply)` gives `{ value, setBase, pending, pendingCount, error, commit, reset }`. `commit(update, task)` applies the update instantly, then runs the task; on success the update is promoted into the base truth (no flicker while the server catches up), and on failure it is rolled back, `error()` is set, and the error is rethrown. `setBase()` folds fresh server truth in (after a refetch); write `apply` idempotently, for example upsert by id, so truth that already includes an optimistic update does not duplicate it. `reset()` drops in-flight updates. Pure signals, SSR-safe.
|
|
1619
|
+
|
|
1574
1620
|
### Easings
|
|
1575
1621
|
|
|
1576
1622
|
Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack`, plus the cartoon set: `easeInBack` (anticipation dip before movement), `easeInOutBack` (wind-up, overshoot, settle), `easeOutElastic` (decaying rubber-band oscillation), `easeOutBounce` (shrinking cartoon bounces). Also `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
export interface BatteryState {
|
|
2
|
+
supported: boolean;
|
|
3
|
+
charging: () => boolean;
|
|
4
|
+
level: () => number;
|
|
5
|
+
chargingTime: () => number;
|
|
6
|
+
dischargingTime: () => number;
|
|
7
|
+
error: () => Error | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* createBattery
|
|
11
|
+
*
|
|
12
|
+
* Reactive wrapper around the Battery Status API (`navigator.getBattery`).
|
|
13
|
+
* Tracks charging state, level (0..1), and charge/discharge time in seconds.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createBattery(): BatteryState;
|
|
16
|
+
export interface NetworkState {
|
|
17
|
+
online: () => boolean;
|
|
18
|
+
effectiveType: () => string | undefined;
|
|
19
|
+
downlink: () => number | undefined;
|
|
20
|
+
rtt: () => number | undefined;
|
|
21
|
+
saveData: () => boolean;
|
|
22
|
+
supported: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* createNetwork
|
|
26
|
+
*
|
|
27
|
+
* Reactive network status: `navigator.onLine` plus the Network Information
|
|
28
|
+
* API (`navigator.connection`) for effective type, downlink, RTT, and
|
|
29
|
+
* data-saver preference.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createNetwork(): NetworkState;
|
|
32
|
+
export interface WakeLockState {
|
|
33
|
+
supported: boolean;
|
|
34
|
+
active: () => boolean;
|
|
35
|
+
error: () => Error | null;
|
|
36
|
+
request: () => Promise<void>;
|
|
37
|
+
release: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* createWakeLock
|
|
41
|
+
*
|
|
42
|
+
* Keeps the screen awake with the Screen Wake Lock API. Re-acquires the lock
|
|
43
|
+
* automatically when the tab becomes visible again after a release.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createWakeLock(): WakeLockState;
|
|
46
|
+
export interface PickedContact {
|
|
47
|
+
name: string[];
|
|
48
|
+
tel: string[];
|
|
49
|
+
email: string[];
|
|
50
|
+
}
|
|
51
|
+
export interface ContactPickState {
|
|
52
|
+
supported: boolean;
|
|
53
|
+
contacts: () => PickedContact[];
|
|
54
|
+
error: () => Error | null;
|
|
55
|
+
pick: (options?: {
|
|
56
|
+
multiple?: boolean;
|
|
57
|
+
}) => Promise<PickedContact[]>;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* createContactPick
|
|
61
|
+
*
|
|
62
|
+
* Contact Picker API (`navigator.contacts.select`). Resolves with the chosen
|
|
63
|
+
* contacts; empty array when the user cancels.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createContactPick(): ContactPickState;
|
|
66
|
+
export interface OTPState {
|
|
67
|
+
supported: boolean;
|
|
68
|
+
code: () => string | null;
|
|
69
|
+
error: () => Error | null;
|
|
70
|
+
wait: (options?: {
|
|
71
|
+
transport?: string[];
|
|
72
|
+
}) => Promise<string | null>;
|
|
73
|
+
abort: () => void;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* createOTP
|
|
77
|
+
*
|
|
78
|
+
* WebOTP API: reads a one-time code from an incoming SMS without leaving the
|
|
79
|
+
* page. `wait()` resolves with the code (or null when aborted). Works only on
|
|
80
|
+
* secure origins where the SMS matches the site's origin-bound format.
|
|
81
|
+
*/
|
|
82
|
+
export declare function createOTP(): OTPState;
|
|
83
|
+
export interface ShareState {
|
|
84
|
+
supported: boolean;
|
|
85
|
+
canShare: (data: ShareData) => boolean;
|
|
86
|
+
error: () => Error | null;
|
|
87
|
+
share: (data: ShareData) => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* createShare
|
|
91
|
+
*
|
|
92
|
+
* Web Share API: opens the native share sheet. `share()` resolves when the
|
|
93
|
+
* user completes or dismisses the sheet (dismissal is not an error).
|
|
94
|
+
*/
|
|
95
|
+
export declare function createShare(): ShareState;
|
|
96
|
+
export interface NFCRecord {
|
|
97
|
+
recordType: string;
|
|
98
|
+
mediaType?: string;
|
|
99
|
+
text?: string;
|
|
100
|
+
url?: string;
|
|
101
|
+
}
|
|
102
|
+
export interface NFCMessage {
|
|
103
|
+
records: NFCRecord[];
|
|
104
|
+
serialNumber: string;
|
|
105
|
+
}
|
|
106
|
+
export interface NFCState {
|
|
107
|
+
supported: boolean;
|
|
108
|
+
scanning: () => boolean;
|
|
109
|
+
message: () => NFCMessage | null;
|
|
110
|
+
error: () => Error | null;
|
|
111
|
+
scan: () => Promise<void>;
|
|
112
|
+
write: (content: string | {
|
|
113
|
+
records: Array<Record<string, unknown>>;
|
|
114
|
+
}) => Promise<void>;
|
|
115
|
+
abort: () => void;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* createNFC
|
|
119
|
+
*
|
|
120
|
+
* Web NFC (Chrome on Android, secure context): scan tags with `scan()` and
|
|
121
|
+
* read decoded records from `message`; write text or records with `write()`.
|
|
122
|
+
* Scanning needs a user gesture.
|
|
123
|
+
*/
|
|
124
|
+
export declare function createNFC(): NFCState;
|
|
125
|
+
export interface TorchState {
|
|
126
|
+
supported: () => boolean;
|
|
127
|
+
on: () => boolean;
|
|
128
|
+
error: () => Error | null;
|
|
129
|
+
attach: (input: MediaStreamTrack | MediaStream) => void;
|
|
130
|
+
set: (value: boolean) => Promise<void>;
|
|
131
|
+
toggle: () => Promise<void>;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* createTorch
|
|
135
|
+
*
|
|
136
|
+
* Camera flashlight for devices that expose the `torch` capability.
|
|
137
|
+
* Attach a video track (or stream) from `getUserMedia`, then `set()` or
|
|
138
|
+
* `toggle()` the flashlight.
|
|
139
|
+
*/
|
|
140
|
+
export declare function createTorch(): TorchState;
|
|
141
|
+
export interface GyroState {
|
|
142
|
+
supported: boolean;
|
|
143
|
+
needsPermission: boolean;
|
|
144
|
+
alpha: () => number | null;
|
|
145
|
+
beta: () => number | null;
|
|
146
|
+
gamma: () => number | null;
|
|
147
|
+
absolute: () => boolean;
|
|
148
|
+
listening: () => boolean;
|
|
149
|
+
error: () => Error | null;
|
|
150
|
+
requestPermission: () => Promise<"granted" | "denied">;
|
|
151
|
+
start: () => Promise<void>;
|
|
152
|
+
stop: () => void;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* createGyro
|
|
156
|
+
*
|
|
157
|
+
* Device orientation (alpha/beta/gamma in degrees). On iOS the motion
|
|
158
|
+
* permission prompt must come from a user gesture: call `requestPermission()`
|
|
159
|
+
* from a tap handler, then `start()`.
|
|
160
|
+
*/
|
|
161
|
+
export declare function createGyro(): GyroState;
|
|
162
|
+
export interface ShakeOptions {
|
|
163
|
+
/** Acceleration delta (m/s^2) that counts as a shake. Default 15. */
|
|
164
|
+
threshold?: number;
|
|
165
|
+
/** Minimum milliseconds between shakes. Default 800. */
|
|
166
|
+
cooldown?: number;
|
|
167
|
+
onShake?: () => void;
|
|
168
|
+
}
|
|
169
|
+
export interface ShakeState {
|
|
170
|
+
supported: boolean;
|
|
171
|
+
needsPermission: boolean;
|
|
172
|
+
listening: () => boolean;
|
|
173
|
+
shakes: () => number;
|
|
174
|
+
error: () => Error | null;
|
|
175
|
+
requestPermission: () => Promise<"granted" | "denied">;
|
|
176
|
+
start: () => Promise<void>;
|
|
177
|
+
stop: () => void;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* createShake
|
|
181
|
+
*
|
|
182
|
+
* Shake-to-undo style detection from `devicemotion`. Counts a shake when the
|
|
183
|
+
* acceleration delta exceeds `threshold`, rate-limited by `cooldown`.
|
|
184
|
+
*/
|
|
185
|
+
export declare function createShake(options?: ShakeOptions): ShakeState;
|
|
186
|
+
export interface ScanlineOptions {
|
|
187
|
+
/** Milliseconds per sweep. Default 1800. */
|
|
188
|
+
duration?: number;
|
|
189
|
+
/** Sweep pattern. Default "alternate" (ping-pong). */
|
|
190
|
+
direction?: "down" | "up" | "alternate";
|
|
191
|
+
}
|
|
192
|
+
export interface ScanlineState {
|
|
193
|
+
/** Line position, 0 at the top edge and 1 at the bottom edge. */
|
|
194
|
+
progress: () => number;
|
|
195
|
+
running: () => boolean;
|
|
196
|
+
start: () => void;
|
|
197
|
+
stop: () => void;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* createScanline
|
|
201
|
+
*
|
|
202
|
+
* The animated line of a camera viewfinder (QR/barcode scanning UI). Bind
|
|
203
|
+
* `progress()` to the line position, for example
|
|
204
|
+
* `style={{ top: `${progress() * 100}%` }}`. Runs on the shared clock and
|
|
205
|
+
* freezes mid-frame under reduced motion.
|
|
206
|
+
*/
|
|
207
|
+
export declare function createScanline(options?: ScanlineOptions): ScanlineState;
|
package/dist/hardware.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
2
|
+
import { prefersReducedMotion } from "./reduced-motion.js";
|
|
3
|
+
import { schedule } from "./engine.js";
|
|
4
|
+
const g = globalThis;
|
|
5
|
+
function isClient() {
|
|
6
|
+
return typeof globalThis.window !== "undefined";
|
|
7
|
+
}
|
|
8
|
+
function getNavigator() {
|
|
9
|
+
return g["navigator"];
|
|
10
|
+
}
|
|
11
|
+
/** Attach a listener to the global scope (window in browsers). No-op on the server. */
|
|
12
|
+
function onGlobalEvent(type, fn) {
|
|
13
|
+
const add = g["addEventListener"];
|
|
14
|
+
const remove = g["removeEventListener"];
|
|
15
|
+
if (!isClient() || typeof add !== "function" || typeof remove !== "function") {
|
|
16
|
+
return () => { };
|
|
17
|
+
}
|
|
18
|
+
add.call(globalThis, type, fn);
|
|
19
|
+
return () => remove.call(globalThis, type, fn);
|
|
20
|
+
}
|
|
21
|
+
function documentHidden() {
|
|
22
|
+
const doc = g["document"];
|
|
23
|
+
return doc?.hidden ?? false;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* createBattery
|
|
27
|
+
*
|
|
28
|
+
* Reactive wrapper around the Battery Status API (`navigator.getBattery`).
|
|
29
|
+
* Tracks charging state, level (0..1), and charge/discharge time in seconds.
|
|
30
|
+
*/
|
|
31
|
+
export function createBattery() {
|
|
32
|
+
const nav = getNavigator();
|
|
33
|
+
const hasApi = isClient() && typeof nav?.getBattery === "function";
|
|
34
|
+
const [charging, setCharging] = createSignal(false);
|
|
35
|
+
const [level, setLevel] = createSignal(1);
|
|
36
|
+
const [chargingTime, setChargingTime] = createSignal(Infinity);
|
|
37
|
+
const [dischargingTime, setDischargingTime] = createSignal(Infinity);
|
|
38
|
+
const [error, setError] = createSignal(null);
|
|
39
|
+
if (hasApi) {
|
|
40
|
+
let manager = null;
|
|
41
|
+
const sync = () => {
|
|
42
|
+
if (!manager)
|
|
43
|
+
return;
|
|
44
|
+
setCharging(manager.charging);
|
|
45
|
+
setLevel(manager.level);
|
|
46
|
+
setChargingTime(manager.chargingTime);
|
|
47
|
+
setDischargingTime(manager.dischargingTime);
|
|
48
|
+
};
|
|
49
|
+
nav
|
|
50
|
+
.getBattery()
|
|
51
|
+
.then((m) => {
|
|
52
|
+
manager = m;
|
|
53
|
+
sync();
|
|
54
|
+
manager.addEventListener("chargingchange", sync);
|
|
55
|
+
manager.addEventListener("levelchange", sync);
|
|
56
|
+
manager.addEventListener("chargingtimechange", sync);
|
|
57
|
+
manager.addEventListener("dischargingtimechange", sync);
|
|
58
|
+
})
|
|
59
|
+
.catch((e) => {
|
|
60
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
61
|
+
});
|
|
62
|
+
onCleanup(() => {
|
|
63
|
+
manager?.removeEventListener("chargingchange", sync);
|
|
64
|
+
manager?.removeEventListener("levelchange", sync);
|
|
65
|
+
manager?.removeEventListener("chargingtimechange", sync);
|
|
66
|
+
manager?.removeEventListener("dischargingtimechange", sync);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
supported: hasApi,
|
|
71
|
+
charging,
|
|
72
|
+
level,
|
|
73
|
+
chargingTime,
|
|
74
|
+
dischargingTime,
|
|
75
|
+
error,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* createNetwork
|
|
80
|
+
*
|
|
81
|
+
* Reactive network status: `navigator.onLine` plus the Network Information
|
|
82
|
+
* API (`navigator.connection`) for effective type, downlink, RTT, and
|
|
83
|
+
* data-saver preference.
|
|
84
|
+
*/
|
|
85
|
+
export function createNetwork() {
|
|
86
|
+
const nav = getNavigator();
|
|
87
|
+
const conn = nav?.connection;
|
|
88
|
+
const [online, setOnline] = createSignal(typeof nav?.onLine === "boolean" ? nav.onLine : true);
|
|
89
|
+
const [effectiveType, setEffectiveType] = createSignal(conn?.effectiveType);
|
|
90
|
+
const [downlink, setDownlink] = createSignal(conn?.downlink);
|
|
91
|
+
const [rtt, setRtt] = createSignal(conn?.rtt);
|
|
92
|
+
const [saveData, setSaveData] = createSignal(conn?.saveData ?? false);
|
|
93
|
+
const syncConnection = () => {
|
|
94
|
+
setEffectiveType(conn?.effectiveType);
|
|
95
|
+
setDownlink(conn?.downlink);
|
|
96
|
+
setRtt(conn?.rtt);
|
|
97
|
+
setSaveData(conn?.saveData ?? false);
|
|
98
|
+
};
|
|
99
|
+
const off1 = onGlobalEvent("online", () => setOnline(true));
|
|
100
|
+
const off2 = onGlobalEvent("offline", () => setOnline(false));
|
|
101
|
+
if (typeof conn?.addEventListener === "function") {
|
|
102
|
+
conn.addEventListener("change", syncConnection);
|
|
103
|
+
}
|
|
104
|
+
onCleanup(() => {
|
|
105
|
+
off1();
|
|
106
|
+
off2();
|
|
107
|
+
if (typeof conn?.removeEventListener === "function") {
|
|
108
|
+
conn.removeEventListener("change", syncConnection);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
online,
|
|
113
|
+
effectiveType,
|
|
114
|
+
downlink,
|
|
115
|
+
rtt,
|
|
116
|
+
saveData,
|
|
117
|
+
supported: conn != null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* createWakeLock
|
|
122
|
+
*
|
|
123
|
+
* Keeps the screen awake with the Screen Wake Lock API. Re-acquires the lock
|
|
124
|
+
* automatically when the tab becomes visible again after a release.
|
|
125
|
+
*/
|
|
126
|
+
export function createWakeLock() {
|
|
127
|
+
const nav = getNavigator();
|
|
128
|
+
const hasApi = isClient() && typeof nav?.wakeLock?.request === "function";
|
|
129
|
+
const [active, setActive] = createSignal(false);
|
|
130
|
+
const [error, setError] = createSignal(null);
|
|
131
|
+
let sentinel = null;
|
|
132
|
+
let wanted = false;
|
|
133
|
+
const acquire = async () => {
|
|
134
|
+
if (!hasApi) {
|
|
135
|
+
throw new Error("Screen Wake Lock API is not supported.");
|
|
136
|
+
}
|
|
137
|
+
setError(null);
|
|
138
|
+
try {
|
|
139
|
+
const next = (await nav.wakeLock.request("screen"));
|
|
140
|
+
sentinel = next;
|
|
141
|
+
setActive(true);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
145
|
+
setError(err);
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const request = async () => {
|
|
150
|
+
wanted = true;
|
|
151
|
+
await acquire();
|
|
152
|
+
};
|
|
153
|
+
const release = async () => {
|
|
154
|
+
wanted = false;
|
|
155
|
+
const current = sentinel;
|
|
156
|
+
sentinel = null;
|
|
157
|
+
setActive(false);
|
|
158
|
+
if (current) {
|
|
159
|
+
try {
|
|
160
|
+
await current.release();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Releasing an already-released lock is harmless.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const off = onGlobalEvent("visibilitychange", () => {
|
|
168
|
+
if (!documentHidden() && wanted && !active()) {
|
|
169
|
+
void acquire().catch(() => { });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
onCleanup(() => {
|
|
173
|
+
off();
|
|
174
|
+
wanted = false;
|
|
175
|
+
void sentinel?.release().catch(() => { });
|
|
176
|
+
sentinel = null;
|
|
177
|
+
});
|
|
178
|
+
return { supported: hasApi, active, error, request, release };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* createContactPick
|
|
182
|
+
*
|
|
183
|
+
* Contact Picker API (`navigator.contacts.select`). Resolves with the chosen
|
|
184
|
+
* contacts; empty array when the user cancels.
|
|
185
|
+
*/
|
|
186
|
+
export function createContactPick() {
|
|
187
|
+
const nav = getNavigator();
|
|
188
|
+
const hasApi = isClient() && typeof nav?.contacts?.select === "function";
|
|
189
|
+
const [contacts, setContacts] = createSignal([]);
|
|
190
|
+
const [error, setError] = createSignal(null);
|
|
191
|
+
const pick = async (options = {}) => {
|
|
192
|
+
if (!hasApi) {
|
|
193
|
+
throw new Error("Contact Picker API is not supported.");
|
|
194
|
+
}
|
|
195
|
+
setError(null);
|
|
196
|
+
try {
|
|
197
|
+
const selected = (await nav.contacts.select(["name", "tel", "email"], { multiple: options.multiple ?? false }));
|
|
198
|
+
const normalized = (selected ?? []).map((c) => ({
|
|
199
|
+
name: c.name ?? [],
|
|
200
|
+
tel: c.tel ?? [],
|
|
201
|
+
email: c.email ?? [],
|
|
202
|
+
}));
|
|
203
|
+
setContacts(normalized);
|
|
204
|
+
return normalized;
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
208
|
+
setError(err);
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
return { supported: hasApi, contacts, error, pick };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* createOTP
|
|
216
|
+
*
|
|
217
|
+
* WebOTP API: reads a one-time code from an incoming SMS without leaving the
|
|
218
|
+
* page. `wait()` resolves with the code (or null when aborted). Works only on
|
|
219
|
+
* secure origins where the SMS matches the site's origin-bound format.
|
|
220
|
+
*/
|
|
221
|
+
export function createOTP() {
|
|
222
|
+
const nav = getNavigator();
|
|
223
|
+
const hasApi = isClient() && typeof nav?.credentials?.get === "function";
|
|
224
|
+
const [code, setCode] = createSignal(null);
|
|
225
|
+
const [error, setError] = createSignal(null);
|
|
226
|
+
let controller = null;
|
|
227
|
+
const abort = () => {
|
|
228
|
+
controller?.abort();
|
|
229
|
+
controller = null;
|
|
230
|
+
};
|
|
231
|
+
const wait = async (options = {}) => {
|
|
232
|
+
if (!hasApi) {
|
|
233
|
+
throw new Error("WebOTP API is not supported.");
|
|
234
|
+
}
|
|
235
|
+
abort();
|
|
236
|
+
setError(null);
|
|
237
|
+
controller = new AbortController();
|
|
238
|
+
const current = controller;
|
|
239
|
+
try {
|
|
240
|
+
const credential = (await nav.credentials.get({
|
|
241
|
+
otp: { transport: options.transport ?? ["sms"] },
|
|
242
|
+
signal: current.signal,
|
|
243
|
+
}));
|
|
244
|
+
const value = credential?.code ?? null;
|
|
245
|
+
setCode(value);
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
catch (e) {
|
|
249
|
+
if (e?.name === "AbortError")
|
|
250
|
+
return null;
|
|
251
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
252
|
+
setError(err);
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
if (controller === current)
|
|
257
|
+
controller = null;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
onCleanup(abort);
|
|
261
|
+
return { supported: hasApi, code, error, wait, abort };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* createShare
|
|
265
|
+
*
|
|
266
|
+
* Web Share API: opens the native share sheet. `share()` resolves when the
|
|
267
|
+
* user completes or dismisses the sheet (dismissal is not an error).
|
|
268
|
+
*/
|
|
269
|
+
export function createShare() {
|
|
270
|
+
const nav = getNavigator();
|
|
271
|
+
const hasApi = isClient() && typeof nav?.share === "function";
|
|
272
|
+
const [error, setError] = createSignal(null);
|
|
273
|
+
const canShare = (data) => {
|
|
274
|
+
const canShareFn = nav?.canShare;
|
|
275
|
+
if (typeof canShareFn === "function") {
|
|
276
|
+
try {
|
|
277
|
+
return canShareFn.call(nav, data);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return hasApi;
|
|
284
|
+
};
|
|
285
|
+
const share = async (data) => {
|
|
286
|
+
if (!hasApi) {
|
|
287
|
+
throw new Error("Web Share API is not supported.");
|
|
288
|
+
}
|
|
289
|
+
setError(null);
|
|
290
|
+
try {
|
|
291
|
+
await nav.share.call(nav, data);
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
if (e?.name === "AbortError")
|
|
295
|
+
return;
|
|
296
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
297
|
+
setError(err);
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
return { supported: hasApi, canShare, error, share };
|
|
302
|
+
}
|
|
303
|
+
function decodeNFCRecord(record) {
|
|
304
|
+
const out = { recordType: record.recordType };
|
|
305
|
+
if (record.mediaType)
|
|
306
|
+
out.mediaType = record.mediaType;
|
|
307
|
+
if (!record.data)
|
|
308
|
+
return out;
|
|
309
|
+
const bytes = new Uint8Array(record.data.buffer, record.data.byteOffset, record.data.byteLength);
|
|
310
|
+
if (record.recordType === "text" && bytes.length > 1) {
|
|
311
|
+
const status = bytes[0];
|
|
312
|
+
const utf16 = (status & 0x80) !== 0;
|
|
313
|
+
const langLength = status & 0x3f;
|
|
314
|
+
const textBytes = bytes.slice(1 + langLength);
|
|
315
|
+
out.text = new TextDecoder(utf16 ? "utf-16" : "utf-8").decode(textBytes);
|
|
316
|
+
}
|
|
317
|
+
else if (record.recordType === "url") {
|
|
318
|
+
out.url = new TextDecoder().decode(bytes);
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* createNFC
|
|
324
|
+
*
|
|
325
|
+
* Web NFC (Chrome on Android, secure context): scan tags with `scan()` and
|
|
326
|
+
* read decoded records from `message`; write text or records with `write()`.
|
|
327
|
+
* Scanning needs a user gesture.
|
|
328
|
+
*/
|
|
329
|
+
export function createNFC() {
|
|
330
|
+
const readerCtor = g["NDEFReader"];
|
|
331
|
+
const supported = isClient() && typeof readerCtor === "function";
|
|
332
|
+
const [scanning, setScanning] = createSignal(false);
|
|
333
|
+
const [message, setMessage] = createSignal(null);
|
|
334
|
+
const [error, setError] = createSignal(null);
|
|
335
|
+
let controller = null;
|
|
336
|
+
const abort = () => {
|
|
337
|
+
controller?.abort();
|
|
338
|
+
controller = null;
|
|
339
|
+
setScanning(false);
|
|
340
|
+
};
|
|
341
|
+
const scan = async () => {
|
|
342
|
+
if (!supported || !readerCtor) {
|
|
343
|
+
throw new Error("Web NFC is not supported.");
|
|
344
|
+
}
|
|
345
|
+
abort();
|
|
346
|
+
setError(null);
|
|
347
|
+
const reader = new readerCtor();
|
|
348
|
+
controller = new AbortController();
|
|
349
|
+
const current = controller;
|
|
350
|
+
reader.onreading = (event) => {
|
|
351
|
+
const raw = event.message;
|
|
352
|
+
setMessage({
|
|
353
|
+
records: (raw.records ?? []).map(decodeNFCRecord),
|
|
354
|
+
serialNumber: event.serialNumber ?? "",
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
reader.onreadingerror = () => {
|
|
358
|
+
const err = new Error("Could not read the NFC tag. Try another one.");
|
|
359
|
+
setError(err);
|
|
360
|
+
};
|
|
361
|
+
try {
|
|
362
|
+
setScanning(true);
|
|
363
|
+
await reader.scan({ signal: current.signal });
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
setScanning(false);
|
|
367
|
+
if (controller === current)
|
|
368
|
+
controller = null;
|
|
369
|
+
if (e?.name === "AbortError")
|
|
370
|
+
return;
|
|
371
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
372
|
+
setError(err);
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const write = async (content) => {
|
|
377
|
+
if (!supported || !readerCtor) {
|
|
378
|
+
throw new Error("Web NFC is not supported.");
|
|
379
|
+
}
|
|
380
|
+
setError(null);
|
|
381
|
+
const reader = new readerCtor();
|
|
382
|
+
try {
|
|
383
|
+
await reader.write(content);
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
387
|
+
setError(err);
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
onCleanup(abort);
|
|
392
|
+
return { supported, scanning, message, error, scan, write, abort };
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* createTorch
|
|
396
|
+
*
|
|
397
|
+
* Camera flashlight for devices that expose the `torch` capability.
|
|
398
|
+
* Attach a video track (or stream) from `getUserMedia`, then `set()` or
|
|
399
|
+
* `toggle()` the flashlight.
|
|
400
|
+
*/
|
|
401
|
+
export function createTorch() {
|
|
402
|
+
const [supported, setSupported] = createSignal(false);
|
|
403
|
+
const [on, setOn] = createSignal(false);
|
|
404
|
+
const [error, setError] = createSignal(null);
|
|
405
|
+
let track = null;
|
|
406
|
+
const attach = (input) => {
|
|
407
|
+
const next = typeof input.getVideoTracks === "function"
|
|
408
|
+
? input.getVideoTracks()[0] ?? null
|
|
409
|
+
: input;
|
|
410
|
+
track = next;
|
|
411
|
+
const capabilities = (next?.getCapabilities?.() ?? {});
|
|
412
|
+
setSupported(!!capabilities.torch);
|
|
413
|
+
setError(null);
|
|
414
|
+
};
|
|
415
|
+
const set = async (value) => {
|
|
416
|
+
if (!track) {
|
|
417
|
+
throw new Error("No camera track attached. Call attach() first.");
|
|
418
|
+
}
|
|
419
|
+
setError(null);
|
|
420
|
+
try {
|
|
421
|
+
await track.applyConstraints({ advanced: [{ torch: value }] });
|
|
422
|
+
setOn(value);
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
426
|
+
setError(err);
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
const toggle = async () => {
|
|
431
|
+
await set(!on());
|
|
432
|
+
};
|
|
433
|
+
return { supported, on, error, attach, set, toggle };
|
|
434
|
+
}
|
|
435
|
+
async function requestDevicePermission(ctor) {
|
|
436
|
+
const request = ctor
|
|
437
|
+
?.requestPermission;
|
|
438
|
+
if (typeof request !== "function")
|
|
439
|
+
return "granted";
|
|
440
|
+
try {
|
|
441
|
+
return (await request.call(ctor)) === "granted" ? "granted" : "denied";
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
return "denied";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* createGyro
|
|
449
|
+
*
|
|
450
|
+
* Device orientation (alpha/beta/gamma in degrees). On iOS the motion
|
|
451
|
+
* permission prompt must come from a user gesture: call `requestPermission()`
|
|
452
|
+
* from a tap handler, then `start()`.
|
|
453
|
+
*/
|
|
454
|
+
export function createGyro() {
|
|
455
|
+
const ctor = g["DeviceOrientationEvent"];
|
|
456
|
+
const supported = isClient() && typeof ctor !== "undefined";
|
|
457
|
+
const needsPermission = typeof ctor?.requestPermission ===
|
|
458
|
+
"function";
|
|
459
|
+
const [alpha, setAlpha] = createSignal(null);
|
|
460
|
+
const [beta, setBeta] = createSignal(null);
|
|
461
|
+
const [gamma, setGamma] = createSignal(null);
|
|
462
|
+
const [absolute, setAbsolute] = createSignal(false);
|
|
463
|
+
const [listening, setListening] = createSignal(false);
|
|
464
|
+
const [error, setError] = createSignal(null);
|
|
465
|
+
let detach = null;
|
|
466
|
+
const requestPermission = () => requestDevicePermission(ctor);
|
|
467
|
+
const start = async () => {
|
|
468
|
+
if (!supported) {
|
|
469
|
+
throw new Error("Device orientation is not supported.");
|
|
470
|
+
}
|
|
471
|
+
if (listening())
|
|
472
|
+
return;
|
|
473
|
+
setError(null);
|
|
474
|
+
const verdict = await requestDevicePermission(ctor);
|
|
475
|
+
if (verdict !== "granted") {
|
|
476
|
+
const err = new Error("Motion permission was denied.");
|
|
477
|
+
setError(err);
|
|
478
|
+
throw err;
|
|
479
|
+
}
|
|
480
|
+
const handler = (event) => {
|
|
481
|
+
const e = event;
|
|
482
|
+
setAlpha(typeof e.alpha === "number" ? e.alpha : null);
|
|
483
|
+
setBeta(typeof e.beta === "number" ? e.beta : null);
|
|
484
|
+
setGamma(typeof e.gamma === "number" ? e.gamma : null);
|
|
485
|
+
setAbsolute(!!e.absolute);
|
|
486
|
+
};
|
|
487
|
+
detach = onGlobalEvent("deviceorientation", handler);
|
|
488
|
+
setListening(true);
|
|
489
|
+
};
|
|
490
|
+
const stop = () => {
|
|
491
|
+
detach?.();
|
|
492
|
+
detach = null;
|
|
493
|
+
setListening(false);
|
|
494
|
+
};
|
|
495
|
+
onCleanup(stop);
|
|
496
|
+
return {
|
|
497
|
+
supported,
|
|
498
|
+
needsPermission,
|
|
499
|
+
alpha,
|
|
500
|
+
beta,
|
|
501
|
+
gamma,
|
|
502
|
+
absolute,
|
|
503
|
+
listening,
|
|
504
|
+
error,
|
|
505
|
+
requestPermission,
|
|
506
|
+
start,
|
|
507
|
+
stop,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* createShake
|
|
512
|
+
*
|
|
513
|
+
* Shake-to-undo style detection from `devicemotion`. Counts a shake when the
|
|
514
|
+
* acceleration delta exceeds `threshold`, rate-limited by `cooldown`.
|
|
515
|
+
*/
|
|
516
|
+
export function createShake(options = {}) {
|
|
517
|
+
const { threshold = 15, cooldown = 800, onShake } = options;
|
|
518
|
+
const ctor = g["DeviceMotionEvent"];
|
|
519
|
+
const supported = isClient() && typeof ctor !== "undefined";
|
|
520
|
+
const needsPermission = typeof ctor?.requestPermission ===
|
|
521
|
+
"function";
|
|
522
|
+
const [listening, setListening] = createSignal(false);
|
|
523
|
+
const [shakes, setShakes] = createSignal(0);
|
|
524
|
+
const [error, setError] = createSignal(null);
|
|
525
|
+
let detach = null;
|
|
526
|
+
let last = null;
|
|
527
|
+
let lastSample = 0;
|
|
528
|
+
let lastShake = 0;
|
|
529
|
+
const requestPermission = () => requestDevicePermission(ctor);
|
|
530
|
+
const start = async () => {
|
|
531
|
+
if (!supported) {
|
|
532
|
+
throw new Error("Device motion is not supported.");
|
|
533
|
+
}
|
|
534
|
+
if (listening())
|
|
535
|
+
return;
|
|
536
|
+
setError(null);
|
|
537
|
+
const verdict = await requestDevicePermission(ctor);
|
|
538
|
+
if (verdict !== "granted") {
|
|
539
|
+
const err = new Error("Motion permission was denied.");
|
|
540
|
+
setError(err);
|
|
541
|
+
throw err;
|
|
542
|
+
}
|
|
543
|
+
const handler = (event) => {
|
|
544
|
+
const accel = event.accelerationIncludingGravity;
|
|
545
|
+
if (!accel)
|
|
546
|
+
return;
|
|
547
|
+
const now = Date.now();
|
|
548
|
+
const x = accel.x ?? 0;
|
|
549
|
+
const y = accel.y ?? 0;
|
|
550
|
+
const z = accel.z ?? 0;
|
|
551
|
+
if (last && now - lastSample > 40) {
|
|
552
|
+
const dx = x - last.x;
|
|
553
|
+
const dy = y - last.y;
|
|
554
|
+
const dz = z - last.z;
|
|
555
|
+
const delta = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
556
|
+
if (delta > threshold && now - lastShake > cooldown) {
|
|
557
|
+
lastShake = now;
|
|
558
|
+
setShakes((n) => n + 1);
|
|
559
|
+
onShake?.();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
last = { x, y, z };
|
|
563
|
+
lastSample = now;
|
|
564
|
+
};
|
|
565
|
+
detach = onGlobalEvent("devicemotion", handler);
|
|
566
|
+
setListening(true);
|
|
567
|
+
};
|
|
568
|
+
const stop = () => {
|
|
569
|
+
detach?.();
|
|
570
|
+
detach = null;
|
|
571
|
+
last = null;
|
|
572
|
+
setListening(false);
|
|
573
|
+
};
|
|
574
|
+
onCleanup(stop);
|
|
575
|
+
return {
|
|
576
|
+
supported,
|
|
577
|
+
needsPermission,
|
|
578
|
+
listening,
|
|
579
|
+
shakes,
|
|
580
|
+
error,
|
|
581
|
+
requestPermission,
|
|
582
|
+
start,
|
|
583
|
+
stop,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* createScanline
|
|
588
|
+
*
|
|
589
|
+
* The animated line of a camera viewfinder (QR/barcode scanning UI). Bind
|
|
590
|
+
* `progress()` to the line position, for example
|
|
591
|
+
* `style={{ top: `${progress() * 100}%` }}`. Runs on the shared clock and
|
|
592
|
+
* freezes mid-frame under reduced motion.
|
|
593
|
+
*/
|
|
594
|
+
export function createScanline(options = {}) {
|
|
595
|
+
const { duration = 1800, direction = "alternate" } = options;
|
|
596
|
+
const [progress, setProgress] = createSignal(0);
|
|
597
|
+
const [running, setRunning] = createSignal(false);
|
|
598
|
+
let stopClock = null;
|
|
599
|
+
const start = () => {
|
|
600
|
+
if (running())
|
|
601
|
+
return;
|
|
602
|
+
setRunning(true);
|
|
603
|
+
if (prefersReducedMotion() || duration <= 0) {
|
|
604
|
+
setProgress(0.5);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
let startTime = 0;
|
|
608
|
+
let first = true;
|
|
609
|
+
stopClock = schedule((t) => {
|
|
610
|
+
if (first) {
|
|
611
|
+
startTime = t;
|
|
612
|
+
first = false;
|
|
613
|
+
}
|
|
614
|
+
const phase = ((t - startTime) % duration) / duration;
|
|
615
|
+
let value = phase;
|
|
616
|
+
if (direction === "up")
|
|
617
|
+
value = 1 - phase;
|
|
618
|
+
else if (direction === "alternate")
|
|
619
|
+
value = phase < 0.5 ? phase * 2 : 2 - phase * 2;
|
|
620
|
+
setProgress(value);
|
|
621
|
+
return true;
|
|
622
|
+
});
|
|
623
|
+
};
|
|
624
|
+
const stop = () => {
|
|
625
|
+
stopClock?.();
|
|
626
|
+
stopClock = null;
|
|
627
|
+
setRunning(false);
|
|
628
|
+
};
|
|
629
|
+
onCleanup(stop);
|
|
630
|
+
return { progress, running, start, stop };
|
|
631
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -44,3 +44,7 @@ export { createSSEParser, createSSE, createChatModel, } from "./stream.js";
|
|
|
44
44
|
export type { SSEEvent, StreamStatus, SSEOptions, SSEControls, ChatMessage, ChatProviderKind, CustomChatProvider, ChatModelOptions, ChatModelControls, } from "./stream.js";
|
|
45
45
|
export { createVoiceState, createMicLevel, createSpeech, createWaveform, createTTS, createThinking, createPrompt, } from "./voice.js";
|
|
46
46
|
export type { VoiceStatus, VoiceStateControls, MicLevelOptions, MicLevelControls, SpeechOptions, SpeechControls, WaveformOptions, WaveformControls, TTSProvider, TTSOptions, TTSControls, ThinkingOptions, ThinkingControls, PromptOptions, PromptControls, } from "./voice.js";
|
|
47
|
+
export { createBattery, createNetwork, createWakeLock, createContactPick, createOTP, createShare, createNFC, createTorch, createGyro, createShake, createScanline, } from "./hardware.js";
|
|
48
|
+
export type { BatteryState, NetworkState, WakeLockState, PickedContact, ContactPickState, OTPState, ShareState, NFCRecord, NFCMessage, NFCState, TorchState, GyroState, ShakeOptions, ShakeState, ScanlineOptions, ScanlineState, } from "./hardware.js";
|
|
49
|
+
export { createOptimistic } from "./optimistic.js";
|
|
50
|
+
export type { OptimisticControls } from "./optimistic.js";
|
package/dist/index.js
CHANGED
|
@@ -38,3 +38,5 @@ export { createTxLifecycle, createTicker, createMintReveal, createConnectButton,
|
|
|
38
38
|
export { createPoll, shortenAddress, isAddress, formatUnits, parseUnits, CHAINS, createChain, createTokenPrice, createPriceChange, createPriceCompare, createGasPrice, createBalance, createTxReceipt, createBlockNumber, createChainlinkPrice, createNFTMetadata, createENS, createIdenticon, } from "./web3data.js";
|
|
39
39
|
export { createSSEParser, createSSE, createChatModel, } from "./stream.js";
|
|
40
40
|
export { createVoiceState, createMicLevel, createSpeech, createWaveform, createTTS, createThinking, createPrompt, } from "./voice.js";
|
|
41
|
+
export { createBattery, createNetwork, createWakeLock, createContactPick, createOTP, createShare, createNFC, createTorch, createGyro, createShake, createScanline, } from "./hardware.js";
|
|
42
|
+
export { createOptimistic } from "./optimistic.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface OptimisticControls<T, U> {
|
|
2
|
+
/** Server truth plus every in-flight optimistic update, in commit order. */
|
|
3
|
+
value: () => T;
|
|
4
|
+
/** Replace the server truth (for example after a refetch). */
|
|
5
|
+
setBase: (next: T | ((prev: T) => T)) => void;
|
|
6
|
+
/** True while at least one commit is in flight. */
|
|
7
|
+
pending: () => boolean;
|
|
8
|
+
/** Number of commits currently in flight. */
|
|
9
|
+
pendingCount: () => number;
|
|
10
|
+
/** Last commit failure, cleared when the next commit starts. */
|
|
11
|
+
error: () => Error | null;
|
|
12
|
+
/**
|
|
13
|
+
* Apply `update` immediately, then run `task`. On success the update is
|
|
14
|
+
* promoted into the base truth (so the value stays stable while the
|
|
15
|
+
* server catches up); on failure it is rolled back, `error()` is set,
|
|
16
|
+
* and the error is rethrown so callers can react (toast, retry).
|
|
17
|
+
*/
|
|
18
|
+
commit: (update: U, task: (update: U) => Promise<unknown>) => Promise<void>;
|
|
19
|
+
/** Drop every in-flight optimistic update without running their tasks. */
|
|
20
|
+
reset: () => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* createOptimistic
|
|
24
|
+
*
|
|
25
|
+
* Optimistic updates with rollback, in the spirit of React's `useOptimistic`.
|
|
26
|
+
* The UI reflects `update` the moment `commit()` is called; if the backing
|
|
27
|
+
* task (a transaction, a POST, a stream send) fails, that update is rolled
|
|
28
|
+
* back and the error surfaces on `error()`.
|
|
29
|
+
*
|
|
30
|
+
* Write `apply` idempotently (upsert by id rather than blind append) when
|
|
31
|
+
* the server truth delivered through `setBase` may already include an
|
|
32
|
+
* optimistic update.
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* const feed = createOptimistic<Bid[], Bid>([], (bids, bid) =>
|
|
36
|
+
* bids.some((b) => b.id === bid.id) ? bids : [...bids, bid],
|
|
37
|
+
* );
|
|
38
|
+
*
|
|
39
|
+
* await feed.commit(bid, (b) => sendBidTx(b)).catch(() => {
|
|
40
|
+
* // already rolled back; show a toast from feed.error()
|
|
41
|
+
* });
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function createOptimistic<T, U>(initial: T, apply: (state: T, update: U) => T): OptimisticControls<T, U>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
/**
|
|
3
|
+
* createOptimistic
|
|
4
|
+
*
|
|
5
|
+
* Optimistic updates with rollback, in the spirit of React's `useOptimistic`.
|
|
6
|
+
* The UI reflects `update` the moment `commit()` is called; if the backing
|
|
7
|
+
* task (a transaction, a POST, a stream send) fails, that update is rolled
|
|
8
|
+
* back and the error surfaces on `error()`.
|
|
9
|
+
*
|
|
10
|
+
* Write `apply` idempotently (upsert by id rather than blind append) when
|
|
11
|
+
* the server truth delivered through `setBase` may already include an
|
|
12
|
+
* optimistic update.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const feed = createOptimistic<Bid[], Bid>([], (bids, bid) =>
|
|
16
|
+
* bids.some((b) => b.id === bid.id) ? bids : [...bids, bid],
|
|
17
|
+
* );
|
|
18
|
+
*
|
|
19
|
+
* await feed.commit(bid, (b) => sendBidTx(b)).catch(() => {
|
|
20
|
+
* // already rolled back; show a toast from feed.error()
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function createOptimistic(initial, apply) {
|
|
25
|
+
const [base, setBaseSignal] = createSignal(initial);
|
|
26
|
+
const [queue, setQueue] = createSignal([]);
|
|
27
|
+
const [error, setError] = createSignal(null);
|
|
28
|
+
let nextId = 0;
|
|
29
|
+
const value = () => {
|
|
30
|
+
const truth = base();
|
|
31
|
+
const pending = queue();
|
|
32
|
+
if (pending.length === 0)
|
|
33
|
+
return truth;
|
|
34
|
+
return pending.reduce((state, entry) => apply(state, entry.update), truth);
|
|
35
|
+
};
|
|
36
|
+
const setBase = (next) => {
|
|
37
|
+
if (typeof next === "function") {
|
|
38
|
+
setBaseSignal(next);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
setBaseSignal(() => next);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const commit = async (update, task) => {
|
|
45
|
+
const id = nextId++;
|
|
46
|
+
setError(null);
|
|
47
|
+
setQueue((q) => [...q, { id, update }]);
|
|
48
|
+
try {
|
|
49
|
+
await task(update);
|
|
50
|
+
// Promote the confirmed update into the base truth so the value
|
|
51
|
+
// stays stable while the server truth catches up via setBase.
|
|
52
|
+
setBaseSignal((prev) => apply(prev, update));
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
56
|
+
setError(err);
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
setQueue((q) => q.filter((entry) => entry.id !== id));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const reset = () => {
|
|
64
|
+
setQueue([]);
|
|
65
|
+
setError(null);
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
value,
|
|
69
|
+
setBase,
|
|
70
|
+
pending: () => queue().length > 0,
|
|
71
|
+
pendingCount: () => queue().length,
|
|
72
|
+
error,
|
|
73
|
+
commit,
|
|
74
|
+
reset,
|
|
75
|
+
};
|
|
76
|
+
}
|
package/dist/voice.d.ts
CHANGED
|
@@ -235,7 +235,7 @@ export interface ThinkingControls {
|
|
|
235
235
|
stop: () => void;
|
|
236
236
|
}
|
|
237
237
|
/**
|
|
238
|
-
* An animated "thinking" indicator for voice
|
|
238
|
+
* An animated "thinking" indicator for voice reply latency: cycles
|
|
239
239
|
* trailing dots, then moves through phrases.
|
|
240
240
|
*
|
|
241
241
|
* ```ts
|
package/dist/voice.js
CHANGED
|
@@ -464,7 +464,7 @@ export function createTTS(options = {}) {
|
|
|
464
464
|
return { supported, speaking, voices, speak, cancel };
|
|
465
465
|
}
|
|
466
466
|
/**
|
|
467
|
-
* An animated "thinking" indicator for voice
|
|
467
|
+
* An animated "thinking" indicator for voice reply latency: cycles
|
|
468
468
|
* trailing dots, then moves through phrases.
|
|
469
469
|
*
|
|
470
470
|
* ```ts
|
package/package.json
CHANGED