@wizardingcode/shibaox-tui 0.1.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.
Files changed (61) hide show
  1. package/bunfig.toml +5 -0
  2. package/package.json +42 -0
  3. package/src/app.tsx +300 -0
  4. package/src/component/dialogs/confirm.tsx +29 -0
  5. package/src/component/dialogs/diff.tsx +143 -0
  6. package/src/component/dialogs/help.tsx +99 -0
  7. package/src/component/dialogs/keys.tsx +201 -0
  8. package/src/component/dialogs/palette.tsx +95 -0
  9. package/src/component/dialogs/runs.tsx +115 -0
  10. package/src/component/dialogs/tiers.tsx +240 -0
  11. package/src/component/footer.tsx +66 -0
  12. package/src/component/logo.tsx +29 -0
  13. package/src/component/prompt/autocomplete.tsx +44 -0
  14. package/src/component/prompt/index.tsx +213 -0
  15. package/src/component/reconnecting.tsx +44 -0
  16. package/src/component/sidebar.tsx +235 -0
  17. package/src/component/tabs.tsx +116 -0
  18. package/src/component/too-small.tsx +15 -0
  19. package/src/context/client.tsx +54 -0
  20. package/src/context/commands.tsx +44 -0
  21. package/src/context/config.tsx +22 -0
  22. package/src/context/data-state.ts +41 -0
  23. package/src/context/data.tsx +308 -0
  24. package/src/context/exit.tsx +13 -0
  25. package/src/context/keys.tsx +70 -0
  26. package/src/context/poller.ts +352 -0
  27. package/src/context/prefs.tsx +54 -0
  28. package/src/context/route.tsx +23 -0
  29. package/src/main.tsx +55 -0
  30. package/src/model/format.ts +38 -0
  31. package/src/model/prompt-commands.ts +315 -0
  32. package/src/model/status.ts +55 -0
  33. package/src/model/stream.ts +464 -0
  34. package/src/motion/animation.ts +235 -0
  35. package/src/motion/config.tsx +15 -0
  36. package/src/motion/fade-in-text.tsx +93 -0
  37. package/src/motion/masked-text.ts +62 -0
  38. package/src/motion/one-cell-motion.ts +100 -0
  39. package/src/motion/pulse.ts +38 -0
  40. package/src/motion/shimmer-text.tsx +78 -0
  41. package/src/motion/spinner.tsx +101 -0
  42. package/src/motion/subcell.ts +40 -0
  43. package/src/routes/home.tsx +365 -0
  44. package/src/routes/session/approval-bar.tsx +136 -0
  45. package/src/routes/session/cards.tsx +495 -0
  46. package/src/routes/session/context-line.tsx +129 -0
  47. package/src/routes/session/continue.tsx +104 -0
  48. package/src/routes/session/index.tsx +340 -0
  49. package/src/routes/session/request.tsx +87 -0
  50. package/src/routes/session/timeline.tsx +313 -0
  51. package/src/testing/fake-client.ts +249 -0
  52. package/src/theme/context.tsx +44 -0
  53. package/src/theme/resolve.ts +196 -0
  54. package/src/theme/shibaox.json +159 -0
  55. package/src/ui/border.ts +20 -0
  56. package/src/ui/delayed-presence.ts +23 -0
  57. package/src/ui/dialog.tsx +123 -0
  58. package/src/ui/layout.ts +23 -0
  59. package/src/ui/marquee.ts +75 -0
  60. package/src/ui/pane-resize.ts +77 -0
  61. package/src/ui/toast.tsx +203 -0
@@ -0,0 +1,464 @@
1
+ import type { NodeStatus, RunState, RunStatus } from '@wizardingcode/shibaox-core';
2
+ import type { Envelope } from '@wizardingcode/shibaox-daemon';
3
+ import type { WorkflowNode } from '@wizardingcode/shibaox-schemas';
4
+
5
+ /** `key` is stable across reductions so the screen can reconcile instead of remounting. */
6
+ export type Block =
7
+ | { kind: 'text'; key: string; text: string; parentId?: string }
8
+ | {
9
+ kind: 'tool';
10
+ key: string;
11
+ id: string;
12
+ name: string;
13
+ summary: string;
14
+ input: unknown;
15
+ output?: unknown;
16
+ ms?: number;
17
+ status: 'running' | 'done' | 'error';
18
+ parentId?: string;
19
+ }
20
+ | { kind: 'file'; key: string; path: string };
21
+
22
+ export type Card =
23
+ | {
24
+ kind: 'node';
25
+ key: string;
26
+ nodeId: string;
27
+ type: 'task' | 'code';
28
+ role?: string;
29
+ runtime?: string;
30
+ status: NodeStatus;
31
+ attempts: number;
32
+ costUsd?: number;
33
+ startedAt?: string;
34
+ endedAt?: string;
35
+ blocks: Block[];
36
+ tools: number;
37
+ /** The model at work and how full its context is (latest `usage` frame of the node). */
38
+ usage?: RunUsage;
39
+ }
40
+ | {
41
+ kind: 'gate';
42
+ key: string;
43
+ nodeId: string;
44
+ status: NodeStatus;
45
+ checks: { name: string; passed: boolean; ms?: number; message?: string }[];
46
+ passed?: boolean;
47
+ report?: string;
48
+ attempts: number;
49
+ }
50
+ | {
51
+ kind: 'decide';
52
+ key: string;
53
+ nodeId: string;
54
+ status: NodeStatus;
55
+ choice?: string;
56
+ confidence?: number;
57
+ }
58
+ | {
59
+ kind: 'human';
60
+ key: string;
61
+ nodeId: string;
62
+ prompt: string;
63
+ action?: string;
64
+ pending: boolean;
65
+ answer?: { approved: boolean; note?: string; via?: string; at: string };
66
+ }
67
+ | { kind: 'error'; key: string; nodeId?: string; message: string }
68
+ | { kind: 'earlier'; key: string; count: number }
69
+ | {
70
+ kind: 'summary';
71
+ key: string;
72
+ status: RunStatus;
73
+ costUsd: number;
74
+ durationMs?: number;
75
+ files: string[];
76
+ branch?: string;
77
+ error?: string;
78
+ };
79
+
80
+ export const CARD_LIMIT = 5000;
81
+
82
+ export interface RunUsage {
83
+ model?: string;
84
+ contextTokens?: number;
85
+ contextWindow?: number;
86
+ inputTokens?: number;
87
+ outputTokens?: number;
88
+ }
89
+
90
+ /** The latest usage of a run: the last node card that reported one. */
91
+ export function runUsage(cards: readonly Card[]): RunUsage | undefined {
92
+ for (let i = cards.length - 1; i >= 0; i--) {
93
+ const c = cards[i];
94
+ if (c?.kind === 'node' && c.usage) return c.usage;
95
+ }
96
+ return undefined;
97
+ }
98
+
99
+ /** A conversation turn: a run of a workflow marked `conversation: true` (the org's `chat`). */
100
+ export function isChatRun(state: RunState | undefined): boolean {
101
+ return state?.workflowSnapshot?.conversation === true;
102
+ }
103
+
104
+ /** A turn shibaox submitted itself (a dispatched run ended), never typed by the user. */
105
+ export function isEventRun(state: RunState | undefined): boolean {
106
+ return state?.input.event === true;
107
+ }
108
+ const SUMMARY_LIMIT = 80;
109
+
110
+ /** Run events after which the dashboard refreshes the run's state. */
111
+ export const RUN_EVENT_REFRESH: ReadonlySet<string> = new Set([
112
+ 'NodeStarted',
113
+ 'NodeCompleted',
114
+ 'NodeFailed',
115
+ 'NodeSuspended',
116
+ 'GatePassed',
117
+ 'GateFailed',
118
+ 'DecisionMade',
119
+ 'HumanRequested',
120
+ 'HumanResponded',
121
+ 'ToolApprovalRequested',
122
+ 'ToolApprovalResolved',
123
+ 'BudgetWarning',
124
+ 'BudgetExceeded',
125
+ 'RunResumed',
126
+ 'RunCompleted',
127
+ 'RunCancelled',
128
+ ]);
129
+
130
+ /** A one-line summary of a tool input: its path or command when it has one, else trimmed JSON. */
131
+ export function summarizeInput(input: unknown, limit = SUMMARY_LIMIT): string {
132
+ if (input === undefined) return '';
133
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
134
+ const o = input as Record<string, unknown>;
135
+ for (const key of ['file_path', 'command']) {
136
+ const v = o[key];
137
+ if (typeof v === 'string' && v) return v.length > limit ? v.slice(0, limit) : v;
138
+ }
139
+ }
140
+ let s: string;
141
+ try {
142
+ s = JSON.stringify(input) ?? String(input);
143
+ } catch {
144
+ s = String(input);
145
+ }
146
+ return s.length > limit ? s.slice(0, limit) : s;
147
+ }
148
+
149
+ type Ev = Record<string, unknown> & { type: string; at?: string; nodeId?: string };
150
+
151
+ const TERMINAL: ReadonlySet<string> = new Set(['completed', 'failed', 'cancelled']);
152
+
153
+ /** Nodes of the snapshot in reading order: breadth-first from `start`, then the rest by key. */
154
+ function nodeOrder(state: RunState | undefined): string[] {
155
+ const wf = state?.workflowSnapshot;
156
+ if (!wf) return Object.keys(state?.nodes ?? {});
157
+ const out: string[] = [];
158
+ const seen = new Set<string>();
159
+ const queue = [wf.start];
160
+ while (queue.length > 0) {
161
+ const id = queue.shift() as string;
162
+ if (seen.has(id) || !wf.nodes[id]) continue;
163
+ seen.add(id);
164
+ out.push(id);
165
+ const node = wf.nodes[id] as WorkflowNode;
166
+ const next: string[] =
167
+ node.type === 'decide'
168
+ ? Object.values(node.next)
169
+ : node.type === 'gate'
170
+ ? [node.on_pass, node.on_fail]
171
+ : node.type === 'parallel'
172
+ ? [...node.branches, node.join]
173
+ : node.next
174
+ ? [node.next]
175
+ : [];
176
+ queue.push(...next);
177
+ }
178
+ for (const id of Object.keys(wf.nodes)) if (!seen.has(id)) out.push(id);
179
+ return out;
180
+ }
181
+
182
+ function newCard(state: RunState | undefined, nodeId: string): Card {
183
+ const def = state?.workflowSnapshot?.nodes[nodeId] as WorkflowNode | undefined;
184
+ const st = state?.nodes[nodeId];
185
+ const status: NodeStatus = st?.status ?? 'pending';
186
+ const attempts = st?.attempts ?? 0;
187
+ switch (def?.type) {
188
+ case 'gate':
189
+ return {
190
+ kind: 'gate',
191
+ key: `card:${nodeId}`,
192
+ nodeId,
193
+ status,
194
+ attempts,
195
+ checks: st?.report?.checks.map(check) ?? [],
196
+ passed: st?.report?.passed,
197
+ report: st?.report ? failures(st.report.checks) : undefined,
198
+ };
199
+ case 'decide':
200
+ return { kind: 'decide', key: `card:${nodeId}`, nodeId, status, choice: st?.choice };
201
+ case 'human':
202
+ return {
203
+ kind: 'human',
204
+ key: `card:${nodeId}`,
205
+ nodeId,
206
+ prompt: def.prompt ?? def.action,
207
+ action: def.action,
208
+ pending: state?.pendingHumans.some((p) => p.nodeId === nodeId) ?? false,
209
+ };
210
+ default:
211
+ return {
212
+ kind: 'node',
213
+ key: `card:${nodeId}`,
214
+ nodeId,
215
+ type: def?.type === 'code' || def?.type === 'git' ? 'code' : 'task',
216
+ role: def?.type === 'task' ? def.role : undefined,
217
+ runtime: def?.type === 'task' ? state?.adapter : undefined,
218
+ status,
219
+ attempts,
220
+ costUsd: undefined,
221
+ blocks: [],
222
+ tools: 0,
223
+ };
224
+ }
225
+ }
226
+
227
+ function check(c: { name: string; passed: boolean; evidence: string }) {
228
+ return { name: c.name, passed: c.passed, message: c.evidence || undefined };
229
+ }
230
+
231
+ function failures(checks: { passed: boolean; evidence: string }[]): string | undefined {
232
+ const lines = checks.filter((c) => !c.passed && c.evidence).map((c) => c.evidence);
233
+ return lines.length > 0 ? lines.join('\n') : undefined;
234
+ }
235
+
236
+ /**
237
+ * Folds the run's state and its SSE frames into the cards of the session view: one per node
238
+ * in the order the nodes started, runtime frames inside their node's card, errors and the
239
+ * summary as cards of their own. Without frames the cards come from the state alone.
240
+ */
241
+ export function reduceTimeline(state: RunState | undefined, frames: Envelope[]): Card[] {
242
+ const cards: Card[] = [];
243
+ const byNode = new Map<string, Card>();
244
+ const files = new Set<string>();
245
+ let startedAt: string | undefined;
246
+ let lastAt: string | undefined;
247
+ let ended = false;
248
+
249
+ const cardFor = (nodeId: string): Card => {
250
+ let c = byNode.get(nodeId);
251
+ if (!c) {
252
+ c = newCard(state, nodeId);
253
+ byNode.set(nodeId, c);
254
+ cards.push(c);
255
+ }
256
+ return c;
257
+ };
258
+ const cost = (c: Card, ev: Ev) => {
259
+ const usd = (ev.cost as { usd?: number } | undefined)?.usd;
260
+ if (c.kind === 'node' && typeof usd === 'number') c.costUsd = (c.costUsd ?? 0) + usd;
261
+ };
262
+ const finish = (c: Card, ev: Ev, status: NodeStatus) => {
263
+ if (c.kind === 'node') {
264
+ c.endedAt = ev.at;
265
+ if (!state?.nodes[c.nodeId]) c.status = status;
266
+ } else if ((c.kind === 'gate' || c.kind === 'decide') && !state?.nodes[c.nodeId])
267
+ c.status = status;
268
+ cost(c, ev);
269
+ };
270
+
271
+ for (const frame of frames) {
272
+ if (frame.kind === 'end') {
273
+ ended = true;
274
+ continue;
275
+ }
276
+ if (frame.kind === 'run') {
277
+ const ev = frame.event as unknown as Ev;
278
+ lastAt = ev.at ?? lastAt;
279
+ switch (ev.type) {
280
+ case 'RunStarted':
281
+ startedAt = ev.at;
282
+ break;
283
+ case 'NodeStarted': {
284
+ const c = cardFor(ev.nodeId as string);
285
+ if ('attempts' in c && !state?.nodes[c.nodeId]) c.attempts += 1;
286
+ if (c.kind === 'node') {
287
+ c.startedAt = ev.at;
288
+ if (!state?.nodes[c.nodeId]) c.status = 'running';
289
+ }
290
+ break;
291
+ }
292
+ case 'NodeCompleted':
293
+ finish(cardFor(ev.nodeId as string), ev, 'completed');
294
+ break;
295
+ case 'NodeFailed':
296
+ finish(cardFor(ev.nodeId as string), ev, 'failed');
297
+ break;
298
+ case 'GatePassed':
299
+ case 'GateFailed': {
300
+ const c = cardFor(ev.nodeId as string);
301
+ const report = ev.report as {
302
+ passed: boolean;
303
+ checks: { name: string; passed: boolean; evidence: string }[];
304
+ };
305
+ if (c.kind === 'gate') {
306
+ c.checks = report.checks.map(check);
307
+ c.passed = report.passed;
308
+ c.report = failures(report.checks);
309
+ }
310
+ finish(c, ev, ev.type === 'GatePassed' ? 'passed' : 'gate_failed');
311
+ break;
312
+ }
313
+ case 'DecisionMade': {
314
+ const c = cardFor(ev.nodeId as string);
315
+ if (c.kind === 'decide') {
316
+ c.choice = ev.choice as string;
317
+ c.confidence = ev.confidence as number | undefined;
318
+ }
319
+ cost(c, ev);
320
+ break;
321
+ }
322
+ case 'HumanRequested': {
323
+ const c = cardFor(ev.nodeId as string);
324
+ if (c.kind === 'human') {
325
+ c.prompt = ev.prompt as string;
326
+ c.action = ev.action as string;
327
+ }
328
+ break;
329
+ }
330
+ case 'HumanResponded': {
331
+ const c = cardFor(ev.nodeId as string);
332
+ if (c.kind === 'human') {
333
+ c.answer = {
334
+ approved: ev.approved as boolean,
335
+ note: ev.note as string | undefined,
336
+ at: ev.at ?? '',
337
+ };
338
+ c.pending = false;
339
+ }
340
+ break;
341
+ }
342
+ case 'RunCompleted':
343
+ case 'RunCancelled':
344
+ ended = true;
345
+ break;
346
+ default:
347
+ break;
348
+ }
349
+ continue;
350
+ }
351
+ // runtime frame
352
+ const env = frame.event;
353
+ const c = cardFor(env.nodeId);
354
+ const rt = env.event as Record<string, unknown> & { type: string };
355
+ if (c.kind !== 'node') {
356
+ if (rt.type === 'file_changed') files.add(rt.path as string);
357
+ continue;
358
+ }
359
+ switch (rt.type) {
360
+ case 'text': {
361
+ const text = rt.text as string;
362
+ const parentId = rt.parentToolUseId as string | undefined;
363
+ const last = c.blocks.at(-1);
364
+ const key = `text:${c.blocks.length}`;
365
+ if (last?.kind === 'text' && last.parentId === parentId) last.text += text;
366
+ else
367
+ c.blocks.push(
368
+ parentId ? { kind: 'text', key, text, parentId } : { kind: 'text', key, text },
369
+ );
370
+ break;
371
+ }
372
+ case 'tool_use': {
373
+ const id = (rt.id as string | undefined) ?? `tool-${c.tools + 1}`;
374
+ const parentId = rt.parentToolUseId as string | undefined;
375
+ c.blocks.push({
376
+ kind: 'tool',
377
+ key: `tool:${id}`,
378
+ id,
379
+ name: rt.name as string,
380
+ summary: summarizeInput(rt.input),
381
+ input: rt.input,
382
+ status: 'running',
383
+ ...(parentId ? { parentId } : {}),
384
+ });
385
+ c.tools += 1;
386
+ break;
387
+ }
388
+ case 'tool_result': {
389
+ const id = rt.id as string | undefined;
390
+ const block = [...c.blocks]
391
+ .reverse()
392
+ .find(
393
+ (b): b is Block & { kind: 'tool' } =>
394
+ b.kind === 'tool' &&
395
+ (id ? b.id === id : b.name === rt.name && b.status === 'running'),
396
+ );
397
+ if (block) {
398
+ block.output = rt.output;
399
+ if (typeof rt.durationMs === 'number') block.ms = rt.durationMs;
400
+ block.status = 'done';
401
+ }
402
+ break;
403
+ }
404
+ case 'file_changed':
405
+ files.add(rt.path as string);
406
+ c.blocks.push({ kind: 'file', key: `file:${c.blocks.length}`, path: rt.path as string });
407
+ break;
408
+ case 'usage': {
409
+ const u = rt as RunUsage & { type: string };
410
+ c.usage = {
411
+ ...c.usage,
412
+ ...(u.model ? { model: u.model } : {}),
413
+ ...(u.contextTokens !== undefined ? { contextTokens: u.contextTokens } : {}),
414
+ ...(u.contextWindow !== undefined ? { contextWindow: u.contextWindow } : {}),
415
+ ...(u.inputTokens !== undefined ? { inputTokens: u.inputTokens } : {}),
416
+ ...(u.outputTokens !== undefined ? { outputTokens: u.outputTokens } : {}),
417
+ };
418
+ break;
419
+ }
420
+ case 'error':
421
+ cards.push({
422
+ kind: 'error',
423
+ key: `error:${cards.length}`,
424
+ nodeId: env.nodeId,
425
+ message: rt.message as string,
426
+ });
427
+ break;
428
+ default:
429
+ break;
430
+ }
431
+ }
432
+
433
+ // nodes the state knows but no frame mentioned (no stream, or history already gone)
434
+ if (state)
435
+ for (const id of nodeOrder(state))
436
+ if (state.nodes[id] && !byNode.has(id)) {
437
+ const c = newCard(state, id);
438
+ byNode.set(id, c);
439
+ cards.push(c);
440
+ }
441
+
442
+ // a chat turn that ended well needs no summary line: the reply is the result
443
+ const chatDone = isChatRun(state) && (state?.status ?? 'completed') === 'completed';
444
+ if ((ended || (state && TERMINAL.has(state.status))) && !chatDone) {
445
+ const status: RunStatus = state?.status ?? 'completed';
446
+ cards.push({
447
+ kind: 'summary',
448
+ key: 'summary',
449
+ status,
450
+ costUsd: state?.spentUsd ?? 0,
451
+ durationMs:
452
+ startedAt && lastAt ? Math.max(0, Date.parse(lastAt) - Date.parse(startedAt)) : undefined,
453
+ files: [...files],
454
+ branch: state?.branch,
455
+ error: state?.error,
456
+ });
457
+ }
458
+
459
+ if (cards.length > CARD_LIMIT) {
460
+ const drop = cards.length - CARD_LIMIT;
461
+ return [{ kind: 'earlier', key: 'earlier', count: drop }, ...cards.slice(drop)];
462
+ }
463
+ return cards;
464
+ }
@@ -0,0 +1,235 @@
1
+ // Adapted from opencode (MIT) — https://github.com/anomalyco/opencode
2
+ import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js';
3
+
4
+ type AnimatableValue = number | readonly number[];
5
+ type AnimatableTarget = Record<string, AnimatableValue>;
6
+ type Ease = (progress: number) => number;
7
+
8
+ interface SpringTransition {
9
+ type: 'spring';
10
+ visualDuration: number;
11
+ restDelta: number;
12
+ restSpeed: number;
13
+ }
14
+ interface TweenTransition {
15
+ type: 'tween';
16
+ duration: number;
17
+ ease: Ease;
18
+ }
19
+ export type Transition = SpringTransition | TweenTransition;
20
+
21
+ interface ValueState {
22
+ scalar: boolean;
23
+ value: number[];
24
+ target: number[];
25
+ velocity: number[];
26
+ from: number[];
27
+ }
28
+
29
+ type AnimationTask = (now: number) => boolean;
30
+
31
+ const tasks = new Set<AnimationTask>();
32
+ let timer: ReturnType<typeof setInterval> | undefined;
33
+
34
+ const smoothstep = (progress: number) => progress * progress * (3 - 2 * progress);
35
+
36
+ export function spring(options: {
37
+ visualDuration: number;
38
+ restDelta?: number;
39
+ restSpeed?: number;
40
+ }): Transition {
41
+ return {
42
+ type: 'spring',
43
+ visualDuration: options.visualDuration,
44
+ restDelta: options.restDelta ?? 0.002,
45
+ restSpeed: options.restSpeed ?? 0.002,
46
+ };
47
+ }
48
+
49
+ export function tween(options: { duration: number; ease?: Ease }): Transition {
50
+ return { type: 'tween', duration: options.duration, ease: options.ease ?? smoothstep };
51
+ }
52
+
53
+ /** A set of numbers that move towards their target on a shared 60 fps clock (or jump when disabled). */
54
+ export function createAnimatable<T extends AnimatableTarget>(
55
+ initial: T,
56
+ options: { transition: Transition; enabled?: Accessor<boolean> },
57
+ ): { value: Accessor<T>; animate(next: T): void; jump(next: T): void; stop(): void } {
58
+ const enabled = options.enabled ?? (() => true);
59
+ const state = new Map<string, ValueState>();
60
+ const [value, setValue] = createSignal(clone(initial), { equals: false });
61
+ let target = clone(initial);
62
+ let started = performance.now();
63
+ let previous = started;
64
+
65
+ rebuild(initial);
66
+
67
+ const step: AnimationTask = (now) => {
68
+ if (!enabled()) {
69
+ jump(target);
70
+ return false;
71
+ }
72
+ const delta = Math.min(0.05, (now - previous) / 1_000);
73
+ previous = now;
74
+ const moving = options.transition.type === 'spring' ? advanceSpring(delta) : advanceTween(now);
75
+ if (!moving) {
76
+ jump(target);
77
+ return false;
78
+ }
79
+ setValue(() => read());
80
+ return true;
81
+ };
82
+
83
+ function animate(next: T) {
84
+ if (sameTarget(next)) return;
85
+ target = clone(next);
86
+ if (!enabled() || !sameShape(next)) return jump(next);
87
+ started = performance.now();
88
+ previous = started;
89
+ for (const [key, current] of state) {
90
+ current.from = [...current.value];
91
+ current.target = values(next[key] as AnimatableValue);
92
+ }
93
+ if (settled()) return jump(next);
94
+ schedule(step);
95
+ }
96
+
97
+ function jump(next: T) {
98
+ target = clone(next);
99
+ unschedule(step);
100
+ rebuild(next);
101
+ setValue(() => clone(next));
102
+ }
103
+
104
+ function stop() {
105
+ unschedule(step);
106
+ }
107
+
108
+ function rebuild(next: T) {
109
+ state.clear();
110
+ for (const [key, nextValue] of Object.entries(next)) {
111
+ const v = values(nextValue);
112
+ state.set(key, {
113
+ scalar: typeof nextValue === 'number',
114
+ value: v,
115
+ target: [...v],
116
+ velocity: v.map(() => 0),
117
+ from: [...v],
118
+ });
119
+ }
120
+ started = performance.now();
121
+ previous = started;
122
+ }
123
+
124
+ function sameShape(next: T) {
125
+ const entries = Object.entries(next);
126
+ if (entries.length !== state.size) return false;
127
+ return entries.every(([key, nextValue]) => {
128
+ const current = state.get(key);
129
+ if (!current || current.scalar !== (typeof nextValue === 'number')) return false;
130
+ return current.value.length === values(nextValue).length;
131
+ });
132
+ }
133
+
134
+ function sameTarget(next: T) {
135
+ if (!sameShape(next)) return false;
136
+ return Object.entries(next).every(([key, v]) =>
137
+ values(v).every((part, index) => part === state.get(key)?.target[index]),
138
+ );
139
+ }
140
+
141
+ function settled() {
142
+ return [...state.values()].every(
143
+ (current) =>
144
+ current.value.every((v, index) => v === current.target[index]) &&
145
+ current.velocity.every((v) => v === 0),
146
+ );
147
+ }
148
+
149
+ function advanceSpring(delta: number) {
150
+ const transition = options.transition;
151
+ if (transition.type !== 'spring') return false;
152
+ const frequency = (2 * Math.PI) / (Math.max(0.001, transition.visualDuration) * 1.2);
153
+ let moving = false;
154
+ for (const current of state.values()) {
155
+ current.value.forEach((v, index) => {
156
+ const t = current.target[index] ?? v;
157
+ const velocity = current.velocity[index] ?? 0;
158
+ const offset = v - t;
159
+ const decay = Math.exp(-frequency * delta);
160
+ const nextVelocity = velocity + frequency * offset;
161
+ current.value[index] = t + (offset + nextVelocity * delta) * decay;
162
+ current.velocity[index] = (velocity - frequency * nextVelocity * delta) * decay;
163
+ moving ||=
164
+ Math.abs((current.value[index] ?? 0) - t) > transition.restDelta ||
165
+ Math.abs(current.velocity[index] ?? 0) > transition.restSpeed;
166
+ });
167
+ }
168
+ return moving;
169
+ }
170
+
171
+ function advanceTween(now: number) {
172
+ const transition = options.transition;
173
+ if (transition.type !== 'tween') return false;
174
+ const progress = Math.min(1, (now - started) / 1_000 / Math.max(0.001, transition.duration));
175
+ const eased = transition.ease(progress);
176
+ for (const current of state.values())
177
+ current.value.forEach((_, index) => {
178
+ const from = current.from[index] ?? 0;
179
+ current.value[index] = from + ((current.target[index] ?? from) - from) * eased;
180
+ current.velocity[index] = 0;
181
+ });
182
+ return progress < 1;
183
+ }
184
+
185
+ function read() {
186
+ return Object.fromEntries(
187
+ [...state].map(([key, current]) => [
188
+ key,
189
+ current.scalar ? (current.value[0] ?? 0) : [...current.value],
190
+ ]),
191
+ ) as T;
192
+ }
193
+
194
+ createEffect(() => {
195
+ if (enabled()) return;
196
+ jump(target);
197
+ });
198
+ onCleanup(stop);
199
+
200
+ return { value, animate, jump, stop };
201
+ }
202
+
203
+ function values(value: AnimatableValue): number[] {
204
+ return typeof value === 'number' ? [value] : [...value];
205
+ }
206
+
207
+ function clone<T extends AnimatableTarget>(target: T): T {
208
+ return Object.fromEntries(
209
+ Object.entries(target).map(([key, value]) => [
210
+ key,
211
+ typeof value === 'number' ? value : [...value],
212
+ ]),
213
+ ) as T;
214
+ }
215
+
216
+ function schedule(task: AnimationTask) {
217
+ tasks.add(task);
218
+ if (timer) return;
219
+ timer = setInterval(tick, 16);
220
+ }
221
+
222
+ function unschedule(task: AnimationTask) {
223
+ tasks.delete(task);
224
+ if (tasks.size > 0 || !timer) return;
225
+ clearInterval(timer);
226
+ timer = undefined;
227
+ }
228
+
229
+ function tick() {
230
+ const now = performance.now();
231
+ for (const task of tasks) if (!task(now)) tasks.delete(task);
232
+ if (tasks.size > 0 || !timer) return;
233
+ clearInterval(timer);
234
+ timer = undefined;
235
+ }
@@ -0,0 +1,15 @@
1
+ import { type Accessor, createContext, type JSX, type ParentProps, useContext } from 'solid-js';
2
+
3
+ const Context = createContext<Accessor<boolean>>(() => true);
4
+
5
+ /** Whether animations run; off with `SHIBAOX_NO_MOTION=1` or `prefs.animations === false`. */
6
+ export function MotionProvider(props: ParentProps<{ enabled: boolean }>): JSX.Element {
7
+ return <Context.Provider value={() => props.enabled}>{props.children}</Context.Provider>;
8
+ }
9
+
10
+ export function useMotion(): Accessor<boolean> {
11
+ return useContext(Context);
12
+ }
13
+
14
+ export const motionEnabled = (env: NodeJS.ProcessEnv, prefs?: { animations?: boolean }): boolean =>
15
+ env.SHIBAOX_NO_MOTION !== '1' && prefs?.animations !== false;