@groeponline/pi-wishcraft 1.1.0 → 1.2.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/CHANGELOG.md +13 -0
- package/README.md +16 -7
- package/ROADMAP.md +41 -260
- package/docs/commands.md +6 -2
- package/docs/configuration.md +21 -0
- package/docs/design/accessibility.md +71 -0
- package/docs/design/deck-layout.md +69 -0
- package/docs/design/motion-gallery.md +80 -0
- package/docs/design/motion-system.md +119 -0
- package/docs/design/presets.md +196 -0
- package/docs/design/regression-testing.md +54 -0
- package/docs/design/responsive.md +53 -0
- package/docs/design/signal.md +68 -0
- package/docs/design/theme-contract.md +132 -0
- package/docs/design/vnext-overview.md +55 -0
- package/docs/design/vnext-release-plan.md +212 -0
- package/docs/index.md +19 -1
- package/docs/segments.md +1 -1
- package/package.json +2 -1
- package/src/config/appearance.ts +230 -0
- package/src/config/parse.ts +41 -0
- package/src/config/presets.ts +184 -0
- package/src/config/structural-presets.ts +555 -0
- package/src/config/tokens.ts +154 -0
- package/src/config/types.ts +210 -2
- package/src/extension/commands/commands.ts +36 -12
- package/src/extension/commands/powerline-completions.ts +4 -0
- package/src/extension/commands/queue-commands.ts +6 -0
- package/src/extension/core/segment-context.ts +7 -1
- package/src/extension/core/state.ts +14 -0
- package/src/extension/core/types.ts +7 -0
- package/src/extension/session/session-lifecycle.ts +20 -0
- package/src/extension/settings/appearance-write.ts +135 -0
- package/src/extension/settings/wishcraft-config-items.ts +119 -0
- package/src/extension/settings/wishcraft-config.ts +28 -114
- package/src/extension/skills/skill-manager.ts +5 -0
- package/src/extension/ui/deck/component.ts +358 -0
- package/src/extension/ui/deck/index.ts +34 -0
- package/src/extension/ui/deck/render.ts +260 -0
- package/src/extension/ui/deck/route-bodies.ts +209 -0
- package/src/extension/ui/deck/routes.ts +37 -0
- package/src/extension/ui/deck/session-snapshot.ts +104 -0
- package/src/extension/ui/deck/types.ts +92 -0
- package/src/extension/ui/powerline-menu-view.ts +19 -15
- package/src/extension/ui/signal-layout.ts +77 -0
- package/src/extension/ui/status-line-renderers.ts +20 -2
- package/src/motion/accessibility.ts +59 -0
- package/src/motion/catalog-extra.ts +429 -0
- package/src/motion/catalog.ts +336 -0
- package/src/motion/composer.ts +147 -0
- package/src/motion/frames.ts +84 -0
- package/src/motion/gallery.ts +77 -0
- package/src/motion/index.ts +78 -0
- package/src/motion/policy.ts +128 -0
- package/src/motion/scheduler.ts +159 -0
- package/src/motion/types.ts +132 -0
- package/src/render/timer.ts +1 -0
- package/src/signal/controller.ts +123 -0
- package/src/signal/integration.ts +42 -0
- package/src/signal/render.ts +178 -0
- package/src/theme/detect.ts +48 -0
- package/src/theme/tokens/index.ts +9 -0
- package/src/theme/tokens/mapping.ts +62 -0
- package/src/theme/tokens/types.ts +39 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalog.ts
|
|
3
|
+
* ---------------------------------------------------------------------------
|
|
4
|
+
* Motion definitions plus the event-to-channel matrix and per-channel
|
|
5
|
+
* cadences. Everything here is plain data so the gallery, the composer and the
|
|
6
|
+
* tests can read it without a TUI.
|
|
7
|
+
* ---------------------------------------------------------------------------
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MotionChannel, MotionDef, MotionEvent } from "./types.ts";
|
|
11
|
+
import { EXTRA_MOTIONS } from "./catalog-extra.ts";
|
|
12
|
+
|
|
13
|
+
const CORE_MOTIONS: readonly MotionDef[] = [
|
|
14
|
+
{
|
|
15
|
+
id: "ember-relay",
|
|
16
|
+
name: "Ember Relay",
|
|
17
|
+
category: "wishcraft",
|
|
18
|
+
kind: "generator",
|
|
19
|
+
channels: ["workingGlyph", "signal"],
|
|
20
|
+
colorRole: "motionHot",
|
|
21
|
+
fallbackGlyph: "*",
|
|
22
|
+
loop: "while-active",
|
|
23
|
+
generator: { geometry: "ember", trail: 3, direction: "forward", intervalMs: 90, ease: "pulse" },
|
|
24
|
+
description: "Lanternwake signature. A warm ember travels the connected segments, only while work happens.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "wisp",
|
|
28
|
+
name: "Wisp",
|
|
29
|
+
category: "wishcraft",
|
|
30
|
+
kind: "frames",
|
|
31
|
+
channels: ["workingGlyph", "ambient"],
|
|
32
|
+
colorRole: "motionDim",
|
|
33
|
+
fallbackGlyph: "o",
|
|
34
|
+
loop: "ambient",
|
|
35
|
+
frames: ["◌", "○", "◈", "○"],
|
|
36
|
+
description: "Soft phase drift for minimal presets.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "rune-bloom",
|
|
40
|
+
name: "Rune Bloom",
|
|
41
|
+
category: "wishcraft",
|
|
42
|
+
kind: "generator",
|
|
43
|
+
channels: ["signal", "deckTransient", "borderEmphasis"],
|
|
44
|
+
colorRole: "accent",
|
|
45
|
+
fallbackGlyph: "+",
|
|
46
|
+
loop: "finite",
|
|
47
|
+
generator: { geometry: "bloom", radius: 2, intervalMs: 70, ease: "pulse" },
|
|
48
|
+
description: "Geometry opens briefly on success, capture or insert. No permanent movement.",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "hex-relay",
|
|
52
|
+
name: "Hex Relay",
|
|
53
|
+
category: "wishcraft",
|
|
54
|
+
kind: "generator",
|
|
55
|
+
channels: ["signal", "panelIndicator", "workingGlyph"],
|
|
56
|
+
colorRole: "motionHot",
|
|
57
|
+
fallbackGlyph: "#",
|
|
58
|
+
loop: "while-active",
|
|
59
|
+
generator: { geometry: "heat", trail: 4, direction: "forward", intervalMs: 80, ease: "linear" },
|
|
60
|
+
description: "Heat propagates block by block toward the active tool.",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "stitch-travel",
|
|
64
|
+
name: "Stitch Travel",
|
|
65
|
+
category: "wishcraft",
|
|
66
|
+
kind: "generator",
|
|
67
|
+
channels: ["signal"],
|
|
68
|
+
colorRole: "primary",
|
|
69
|
+
fallbackGlyph: "-",
|
|
70
|
+
loop: "while-active",
|
|
71
|
+
generator: { geometry: "stitch", trail: 2, direction: "forward", intervalMs: 100, ease: "linear" },
|
|
72
|
+
description: "A travelling knot runs from producer to consumer.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "refraction-sweep",
|
|
76
|
+
name: "Refraction Sweep",
|
|
77
|
+
category: "wishcraft",
|
|
78
|
+
kind: "generator",
|
|
79
|
+
channels: ["signal"],
|
|
80
|
+
colorRole: "motionHot",
|
|
81
|
+
fallbackGlyph: ">",
|
|
82
|
+
loop: "while-active",
|
|
83
|
+
generator: { geometry: "refract", trail: 2, intervalMs: 110, ease: "linear" },
|
|
84
|
+
description: "A highlight refracts through the next capsule.",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "writing-reveal",
|
|
88
|
+
name: "Writing Reveal",
|
|
89
|
+
category: "wishcraft",
|
|
90
|
+
kind: "generator",
|
|
91
|
+
channels: ["signal"],
|
|
92
|
+
colorRole: "text",
|
|
93
|
+
fallbackGlyph: "-",
|
|
94
|
+
loop: "while-active",
|
|
95
|
+
generator: { geometry: "write", intervalMs: 90, ease: "linear" },
|
|
96
|
+
description: "The rule grows the way ink grows on vellum.",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "path-traverse",
|
|
100
|
+
name: "Path Traversal",
|
|
101
|
+
category: "wishcraft",
|
|
102
|
+
kind: "generator",
|
|
103
|
+
channels: ["signal", "deckTransient"],
|
|
104
|
+
colorRole: "motionHot",
|
|
105
|
+
fallbackGlyph: "*",
|
|
106
|
+
loop: "while-active",
|
|
107
|
+
generator: { geometry: "path", intervalMs: 120, ease: "linear" },
|
|
108
|
+
description: "Events travel along real constellation edges.",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "liquid-rise",
|
|
112
|
+
name: "Liquid Rise",
|
|
113
|
+
category: "wishcraft",
|
|
114
|
+
kind: "generator",
|
|
115
|
+
channels: ["signal", "panelIndicator"],
|
|
116
|
+
colorRole: "motionHot",
|
|
117
|
+
fallbackGlyph: "=",
|
|
118
|
+
loop: "while-active",
|
|
119
|
+
generator: { geometry: "liquid", intervalMs: 140, ease: "breathe" },
|
|
120
|
+
description: "Activity changes the liquid level instead of spinning.",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "lunar-breathe",
|
|
124
|
+
name: "Lunar Breathe",
|
|
125
|
+
category: "matrix",
|
|
126
|
+
kind: "generator",
|
|
127
|
+
channels: ["workingGlyph", "signal", "ambient"],
|
|
128
|
+
colorRole: "secondary",
|
|
129
|
+
fallbackGlyph: "O",
|
|
130
|
+
loop: "ambient",
|
|
131
|
+
generator: { geometry: "orbit", radius: 2, trail: 3, intervalMs: 280, ease: "breathe" },
|
|
132
|
+
description: "Orbit and phase motion, slow enough for night work.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "lemniscate",
|
|
136
|
+
name: "Lemniscate Pulse",
|
|
137
|
+
category: "matrix",
|
|
138
|
+
kind: "frames",
|
|
139
|
+
channels: ["workingGlyph"],
|
|
140
|
+
colorRole: "accent",
|
|
141
|
+
fallbackGlyph: "8",
|
|
142
|
+
loop: "while-active",
|
|
143
|
+
frames: ["∞", "8"],
|
|
144
|
+
description: "Infinity trace for thinking, without a cheap spinner.",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "petal-shimmer",
|
|
148
|
+
name: "Petal Shimmer",
|
|
149
|
+
category: "matrix",
|
|
150
|
+
kind: "frames",
|
|
151
|
+
channels: ["workingGlyph", "deckTransient"],
|
|
152
|
+
colorRole: "accent",
|
|
153
|
+
fallbackGlyph: "*",
|
|
154
|
+
loop: "finite",
|
|
155
|
+
frames: ["✧", "✦", "✶", "✦"],
|
|
156
|
+
description: "Local bloom near the action, never a screen-wide blocker.",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "wing-metronome",
|
|
160
|
+
name: "Wing Metronome",
|
|
161
|
+
category: "matrix",
|
|
162
|
+
kind: "frames",
|
|
163
|
+
channels: ["workingGlyph"],
|
|
164
|
+
colorRole: "secondary",
|
|
165
|
+
fallbackGlyph: "^",
|
|
166
|
+
loop: "while-active",
|
|
167
|
+
frames: ["△", "▲", "▽", "▼"],
|
|
168
|
+
description: "Symmetric beat for long tool runs.",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: "helix",
|
|
172
|
+
name: "Helix",
|
|
173
|
+
category: "procedural",
|
|
174
|
+
kind: "frames",
|
|
175
|
+
channels: ["workingGlyph", "signal"],
|
|
176
|
+
colorRole: "motionTrail",
|
|
177
|
+
fallbackGlyph: "%",
|
|
178
|
+
loop: "while-active",
|
|
179
|
+
frames: ["⡀", "⡄", "⡆", "⡇", "⣇", "⣧", "⣷", "⣿"],
|
|
180
|
+
description: "Braille helix. Dense but still readable.",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: "braille-wave",
|
|
184
|
+
name: "Braille Wave",
|
|
185
|
+
category: "procedural",
|
|
186
|
+
kind: "frames",
|
|
187
|
+
channels: ["signal"],
|
|
188
|
+
colorRole: "primary",
|
|
189
|
+
fallbackGlyph: "~",
|
|
190
|
+
loop: "while-active",
|
|
191
|
+
frames: ["⠁", "⠃", "⠇", "⠧", "⠷", "⠿"],
|
|
192
|
+
description: "Rising braille wave for lane fills.",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "ripple",
|
|
196
|
+
name: "Ripple",
|
|
197
|
+
category: "procedural",
|
|
198
|
+
kind: "generator",
|
|
199
|
+
channels: ["signal", "panelIndicator"],
|
|
200
|
+
colorRole: "motionTrail",
|
|
201
|
+
fallbackGlyph: "~",
|
|
202
|
+
loop: "finite",
|
|
203
|
+
generator: { geometry: "wave", trail: 5, intervalMs: 70, ease: "pulse" },
|
|
204
|
+
description: "Short wave used for compact and success.",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: "braille-spinner",
|
|
208
|
+
name: "Braille Spinner",
|
|
209
|
+
category: "classic",
|
|
210
|
+
kind: "frames",
|
|
211
|
+
channels: ["workingGlyph"],
|
|
212
|
+
colorRole: "textMuted",
|
|
213
|
+
fallbackGlyph: "|",
|
|
214
|
+
loop: "while-active",
|
|
215
|
+
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
216
|
+
description: "Classic braille spinner. Functional fallback.",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "quarter",
|
|
220
|
+
name: "Quarter",
|
|
221
|
+
category: "classic",
|
|
222
|
+
kind: "frames",
|
|
223
|
+
channels: ["workingGlyph"],
|
|
224
|
+
colorRole: "textMuted",
|
|
225
|
+
fallbackGlyph: "O",
|
|
226
|
+
loop: "while-active",
|
|
227
|
+
frames: ["◐", "◓", "◑", "◒"],
|
|
228
|
+
description: "Quarter-circle phase.",
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: "bar",
|
|
232
|
+
name: "Bar",
|
|
233
|
+
category: "classic",
|
|
234
|
+
kind: "frames",
|
|
235
|
+
channels: ["workingGlyph", "panelIndicator"],
|
|
236
|
+
colorRole: "primary",
|
|
237
|
+
fallbackGlyph: "=",
|
|
238
|
+
loop: "while-active",
|
|
239
|
+
frames: ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"],
|
|
240
|
+
description: "Growing bar for context and compaction.",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "bounce",
|
|
244
|
+
name: "Bounce",
|
|
245
|
+
category: "classic",
|
|
246
|
+
kind: "frames",
|
|
247
|
+
channels: ["workingGlyph"],
|
|
248
|
+
colorRole: "accent",
|
|
249
|
+
fallbackGlyph: "o",
|
|
250
|
+
loop: "while-active",
|
|
251
|
+
frames: ["●", "•", "·", "•"],
|
|
252
|
+
description: "Small local bounce.",
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
export const MOTION_CATALOG: readonly MotionDef[] = [...CORE_MOTIONS, ...EXTRA_MOTIONS];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Which channels an event may light up. Deliberately sparse: idle must not
|
|
260
|
+
* touch the working glyph, and a policy denial must never start a spinner.
|
|
261
|
+
*/
|
|
262
|
+
export const CHANNEL_MATRIX: Record<MotionEvent, readonly MotionChannel[]> = {
|
|
263
|
+
idle: ["ambient"],
|
|
264
|
+
thinking: ["workingGlyph", "signal"],
|
|
265
|
+
streaming: ["workingGlyph", "signal", "panelIndicator"],
|
|
266
|
+
"tool.start": ["workingGlyph", "signal", "panelIndicator"],
|
|
267
|
+
"tool.end": ["signal", "panelIndicator"],
|
|
268
|
+
"idea.capture": ["deckTransient"],
|
|
269
|
+
"skill.insert": ["signal", "deckTransient"],
|
|
270
|
+
"policy.deny": ["deckTransient", "borderEmphasis"],
|
|
271
|
+
repair: ["workingGlyph", "deckTransient", "panelIndicator"],
|
|
272
|
+
compact: ["workingGlyph", "signal", "deckTransient", "panelIndicator"],
|
|
273
|
+
success: ["signal", "deckTransient", "borderEmphasis"],
|
|
274
|
+
warning: ["deckTransient", "borderEmphasis"],
|
|
275
|
+
error: ["deckTransient", "borderEmphasis"],
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** Documented cadence bands per channel. */
|
|
279
|
+
export const CADENCE_MS: Record<MotionChannel, { min: number; max: number }> = {
|
|
280
|
+
workingGlyph: { min: 80, max: 120 },
|
|
281
|
+
signal: { min: 80, max: 120 },
|
|
282
|
+
deckTransient: { min: 50, max: 100 },
|
|
283
|
+
panelIndicator: { min: 120, max: 250 },
|
|
284
|
+
borderEmphasis: { min: 200, max: 400 },
|
|
285
|
+
ambient: { min: 250, max: 750 },
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/** Gallery previews are allowed to run hotter than production channels. */
|
|
289
|
+
export const PREVIEW_INTERVAL_MS = 50;
|
|
290
|
+
|
|
291
|
+
export function getMotion(id: string): MotionDef | undefined {
|
|
292
|
+
return MOTION_CATALOG.find((motion) => motion.id === id);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function channelsForEvent(event: MotionEvent): readonly MotionChannel[] {
|
|
296
|
+
return CHANNEL_MATRIX[event];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function eventUsesChannel(event: MotionEvent, channel: MotionChannel): boolean {
|
|
300
|
+
return CHANNEL_MATRIX[event].includes(channel);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Events that keep animating rather than firing once. */
|
|
304
|
+
export function isContinuous(event: MotionEvent): boolean {
|
|
305
|
+
return (
|
|
306
|
+
event === "idle" ||
|
|
307
|
+
event === "thinking" ||
|
|
308
|
+
event === "streaming" ||
|
|
309
|
+
event === "tool.start"
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function defaultMotionFor(event: MotionEvent): string {
|
|
314
|
+
switch (event) {
|
|
315
|
+
case "idle":
|
|
316
|
+
return "wisp";
|
|
317
|
+
case "thinking":
|
|
318
|
+
case "streaming":
|
|
319
|
+
return "ember-relay";
|
|
320
|
+
case "tool.start":
|
|
321
|
+
return "hex-relay";
|
|
322
|
+
case "tool.end":
|
|
323
|
+
case "success":
|
|
324
|
+
case "idea.capture":
|
|
325
|
+
case "skill.insert":
|
|
326
|
+
return "rune-bloom";
|
|
327
|
+
case "compact":
|
|
328
|
+
return "bar";
|
|
329
|
+
case "repair":
|
|
330
|
+
return "stitch-travel";
|
|
331
|
+
case "policy.deny":
|
|
332
|
+
case "warning":
|
|
333
|
+
case "error":
|
|
334
|
+
return "rune-bloom";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion Composer — tweak a draft, preview it, and turn it into a MotionDef.
|
|
3
|
+
* Assignment of the draft to a semantic event is a separate persist step.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { previewStrip } from "./gallery.ts";
|
|
7
|
+
import type {
|
|
8
|
+
MotionChannel,
|
|
9
|
+
MotionColorRole,
|
|
10
|
+
MotionDef,
|
|
11
|
+
MotionEvent,
|
|
12
|
+
MotionGenerator,
|
|
13
|
+
MotionGeometry,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export type MotionEase = MotionGenerator["ease"];
|
|
17
|
+
|
|
18
|
+
export interface ComposerDraft {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
geometry: MotionGeometry;
|
|
22
|
+
intervalMs: number;
|
|
23
|
+
trail: number;
|
|
24
|
+
direction: "forward" | "reverse";
|
|
25
|
+
ease: MotionEase;
|
|
26
|
+
fallbackGlyph: string;
|
|
27
|
+
colorRole: MotionColorRole;
|
|
28
|
+
channels: MotionChannel[];
|
|
29
|
+
assignEvent: MotionEvent;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const COMPOSER_FIELDS = [
|
|
33
|
+
"geometry",
|
|
34
|
+
"intervalMs",
|
|
35
|
+
"trail",
|
|
36
|
+
"direction",
|
|
37
|
+
"ease",
|
|
38
|
+
"assignEvent",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type ComposerField = (typeof COMPOSER_FIELDS)[number];
|
|
42
|
+
|
|
43
|
+
export const COMPOSER_GEOMETRIES: MotionGeometry[] = [
|
|
44
|
+
"linear",
|
|
45
|
+
"orbit",
|
|
46
|
+
"wave",
|
|
47
|
+
"bloom",
|
|
48
|
+
"liquid",
|
|
49
|
+
"ember",
|
|
50
|
+
"stitch",
|
|
51
|
+
"refract",
|
|
52
|
+
"heat",
|
|
53
|
+
"write",
|
|
54
|
+
"path",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export const COMPOSER_EVENTS: MotionEvent[] = [
|
|
58
|
+
"thinking",
|
|
59
|
+
"streaming",
|
|
60
|
+
"tool.start",
|
|
61
|
+
"idle",
|
|
62
|
+
"success",
|
|
63
|
+
"compact",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export const COMPOSER_EASES: MotionEase[] = ["linear", "pulse", "breathe"];
|
|
67
|
+
|
|
68
|
+
export function draftFromMotion(
|
|
69
|
+
def: MotionDef,
|
|
70
|
+
assignEvent: MotionEvent = "streaming",
|
|
71
|
+
): ComposerDraft {
|
|
72
|
+
return {
|
|
73
|
+
id: def.id,
|
|
74
|
+
name: def.name,
|
|
75
|
+
geometry: def.generator?.geometry ?? "linear",
|
|
76
|
+
intervalMs: def.generator?.intervalMs ?? 100,
|
|
77
|
+
trail: def.generator?.trail ?? 2,
|
|
78
|
+
direction: def.generator?.direction ?? "forward",
|
|
79
|
+
ease: def.generator?.ease ?? "linear",
|
|
80
|
+
fallbackGlyph: def.fallbackGlyph,
|
|
81
|
+
colorRole: def.colorRole,
|
|
82
|
+
channels: [...def.channels],
|
|
83
|
+
assignEvent,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function motionFromDraft(draft: ComposerDraft): MotionDef {
|
|
88
|
+
return {
|
|
89
|
+
id: draft.id,
|
|
90
|
+
name: draft.name,
|
|
91
|
+
category: "custom",
|
|
92
|
+
kind: "generator",
|
|
93
|
+
channels: draft.channels,
|
|
94
|
+
colorRole: draft.colorRole,
|
|
95
|
+
fallbackGlyph: draft.fallbackGlyph,
|
|
96
|
+
loop: draft.assignEvent === "idle" ? "ambient" : "while-active",
|
|
97
|
+
generator: {
|
|
98
|
+
geometry: draft.geometry,
|
|
99
|
+
intervalMs: draft.intervalMs,
|
|
100
|
+
trail: draft.trail,
|
|
101
|
+
direction: draft.direction,
|
|
102
|
+
ease: draft.ease,
|
|
103
|
+
},
|
|
104
|
+
description: `Composer draft of ${draft.name} for ${draft.assignEvent}.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function nudgeComposer(
|
|
109
|
+
draft: ComposerDraft,
|
|
110
|
+
field: ComposerField,
|
|
111
|
+
delta: number,
|
|
112
|
+
): ComposerDraft {
|
|
113
|
+
const next = { ...draft };
|
|
114
|
+
if (field === "intervalMs") {
|
|
115
|
+
next.intervalMs = clamp(draft.intervalMs + delta * 10, 40, 400);
|
|
116
|
+
} else if (field === "trail") {
|
|
117
|
+
next.trail = clamp(draft.trail + delta, 0, 8);
|
|
118
|
+
} else if (field === "direction") {
|
|
119
|
+
next.direction = draft.direction === "forward" ? "reverse" : "forward";
|
|
120
|
+
} else if (field === "geometry") {
|
|
121
|
+
next.geometry = cycle(COMPOSER_GEOMETRIES, draft.geometry, delta);
|
|
122
|
+
} else if (field === "ease") {
|
|
123
|
+
next.ease = cycle(COMPOSER_EASES, draft.ease, delta);
|
|
124
|
+
} else if (field === "assignEvent") {
|
|
125
|
+
next.assignEvent = cycle(COMPOSER_EVENTS, draft.assignEvent, delta);
|
|
126
|
+
}
|
|
127
|
+
return next;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function composerPreview(draft: ComposerDraft, tick: number, width: number): string {
|
|
131
|
+
return previewStrip(motionFromDraft(draft), tick, width);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function cycleAssignEvent(event: MotionEvent, delta = 1): MotionEvent {
|
|
135
|
+
return cycle(COMPOSER_EVENTS, event, delta);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function cycle<T>(list: readonly T[], current: T, delta: number): T {
|
|
139
|
+
const idx = list.indexOf(current);
|
|
140
|
+
const start = idx >= 0 ? idx : 0;
|
|
141
|
+
const next = (start + delta + list.length * 8) % list.length;
|
|
142
|
+
return list[next]!;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function clamp(value: number, min: number, max: number): number {
|
|
146
|
+
return Math.max(min, Math.min(max, value));
|
|
147
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frames.ts
|
|
3
|
+
* ---------------------------------------------------------------------------
|
|
4
|
+
* Frame resolution for motion definitions. Frame arrays and procedural
|
|
5
|
+
* generators both reduce to a glyph for a given tick, with an ASCII fallback.
|
|
6
|
+
*
|
|
7
|
+
* `lanternGlow` is the flicker curve from the welcome lantern (a slow breathe
|
|
8
|
+
* plus a fast ripple). vNext reuses it as Lanternwake's ember, now driven by
|
|
9
|
+
* motion events instead of running unconditionally.
|
|
10
|
+
* ---------------------------------------------------------------------------
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { MotionDef } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
const GEOMETRY_FRAMES: Record<string, string[]> = {
|
|
16
|
+
ember: ["◇", "◈", "◆", "◈"],
|
|
17
|
+
orbit: ["◜", "◝", "◞", "◟"],
|
|
18
|
+
bloom: ["·", "◇", "◈", "◆", "◈", "◇"],
|
|
19
|
+
heat: ["░", "▒", "▓", "█", "▓", "▒"],
|
|
20
|
+
liquid: ["·", "░", "▒", "▓", "█"],
|
|
21
|
+
stitch: ["·", "╼", "◆", "╾"],
|
|
22
|
+
refract: ["╭", "╮", "╯", "╰"],
|
|
23
|
+
write: ["─", "──", "───", "────╾"],
|
|
24
|
+
path: ["·", "✦", "◆", "✦"],
|
|
25
|
+
wave: ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿"],
|
|
26
|
+
linear: ["━", "╾", "◆", "╼"],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Glyph for a motion at a given tick. */
|
|
30
|
+
export function frameAt(def: MotionDef, tick: number, ascii = false): string {
|
|
31
|
+
if (ascii) return def.fallbackGlyph;
|
|
32
|
+
const frames = framesOf(def);
|
|
33
|
+
if (frames.length === 0) return def.fallbackGlyph;
|
|
34
|
+
const index = ((tick % frames.length) + frames.length) % frames.length;
|
|
35
|
+
return frames[index] ?? def.fallbackGlyph;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Glyph for a motion at a given elapsed time, using its own interval. */
|
|
39
|
+
export function frameAtElapsed(def: MotionDef, elapsedMs: number, ascii = false): string {
|
|
40
|
+
const interval = def.generator?.intervalMs ?? 100;
|
|
41
|
+
return frameAt(def, Math.floor(elapsedMs / interval), ascii);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function framesOf(def: MotionDef): string[] {
|
|
45
|
+
if (def.kind === "frames" && def.frames?.length) return def.frames;
|
|
46
|
+
const geometry = def.generator?.geometry ?? "linear";
|
|
47
|
+
return GEOMETRY_FRAMES[geometry] ?? [def.fallbackGlyph];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lantern flicker: slow breathe plus fast ripple, normalised to 0..1.
|
|
52
|
+
* Mirrors the curve in src/welcome/lantern.ts.
|
|
53
|
+
*/
|
|
54
|
+
export function lanternGlow(nowMs: number): number {
|
|
55
|
+
const t = nowMs / 1000;
|
|
56
|
+
const breathe = Math.sin(t * 1.1) * 0.5 + 0.5;
|
|
57
|
+
const ripple = Math.sin(t * 7.3) * 0.5 + 0.5;
|
|
58
|
+
return 0.55 * breathe + 0.45 * ripple;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Position of a travelling head across `width` cells. Returns -1 when the
|
|
63
|
+
* motion is not animating, so callers can render a still rail.
|
|
64
|
+
*/
|
|
65
|
+
export function sweepPosition(
|
|
66
|
+
tick: number,
|
|
67
|
+
width: number,
|
|
68
|
+
animating: boolean,
|
|
69
|
+
direction: "forward" | "reverse" = "forward",
|
|
70
|
+
): number {
|
|
71
|
+
if (!animating || width <= 0) return -1;
|
|
72
|
+
const step = ((tick % width) + width) % width;
|
|
73
|
+
return direction === "reverse" ? width - 1 - step : step;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Trail glyph by distance from the travelling head. */
|
|
77
|
+
export function trailGlyph(distance: number, ascii = false): string {
|
|
78
|
+
if (ascii) return distance === 0 ? "*" : "-";
|
|
79
|
+
if (distance === 0) return "█";
|
|
80
|
+
if (distance === 1) return "▓";
|
|
81
|
+
if (distance === 2) return "▒";
|
|
82
|
+
if (distance === 3) return "░";
|
|
83
|
+
return "━";
|
|
84
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion Gallery — pure catalog queries and preview strips.
|
|
3
|
+
* The Deck and the preview server both read this; nothing here touches ctx.ui.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MOTION_CATALOG } from "./catalog.ts";
|
|
7
|
+
import { frameAt, framesOf, sweepPosition, trailGlyph } from "./frames.ts";
|
|
8
|
+
import type { MotionDef } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export const GALLERY_CATEGORIES = [
|
|
11
|
+
"wishcraft",
|
|
12
|
+
"matrix",
|
|
13
|
+
"procedural",
|
|
14
|
+
"classic",
|
|
15
|
+
"custom",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type GalleryCategory = (typeof GALLERY_CATEGORIES)[number];
|
|
19
|
+
|
|
20
|
+
export function motionMatchesQuery(def: MotionDef, query: string): boolean {
|
|
21
|
+
const q = query.trim().toLowerCase();
|
|
22
|
+
if (!q) return true;
|
|
23
|
+
const hay = `${def.id} ${def.name} ${def.category} ${def.description} ${def.kind}`.toLowerCase();
|
|
24
|
+
return hay.includes(q);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function filterMotions(
|
|
28
|
+
query: string,
|
|
29
|
+
catalog: readonly MotionDef[] = MOTION_CATALOG,
|
|
30
|
+
): MotionDef[] {
|
|
31
|
+
return catalog.filter((def) => motionMatchesQuery(def, query));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function groupMotions(
|
|
35
|
+
motions: readonly MotionDef[],
|
|
36
|
+
): Record<GalleryCategory, MotionDef[]> {
|
|
37
|
+
const groups: Record<GalleryCategory, MotionDef[]> = {
|
|
38
|
+
wishcraft: [],
|
|
39
|
+
matrix: [],
|
|
40
|
+
procedural: [],
|
|
41
|
+
classic: [],
|
|
42
|
+
custom: [],
|
|
43
|
+
};
|
|
44
|
+
for (const def of motions) {
|
|
45
|
+
groups[def.category].push(def);
|
|
46
|
+
}
|
|
47
|
+
return groups;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Travelling-head preview used by the gallery and composer. */
|
|
51
|
+
export function previewStrip(
|
|
52
|
+
def: MotionDef,
|
|
53
|
+
tick: number,
|
|
54
|
+
width: number,
|
|
55
|
+
ascii = false,
|
|
56
|
+
): string {
|
|
57
|
+
const inner = Math.max(8, width);
|
|
58
|
+
const head = frameAt(def, tick, ascii);
|
|
59
|
+
const direction = def.generator?.direction ?? "forward";
|
|
60
|
+
const pos = sweepPosition(tick, inner, true, direction);
|
|
61
|
+
let out = "";
|
|
62
|
+
for (let i = 0; i < inner; i++) {
|
|
63
|
+
const distance = Math.abs(i - pos);
|
|
64
|
+
if (distance === 0) out += head;
|
|
65
|
+
else if (distance <= (def.generator?.trail ?? 2)) out += trailGlyph(distance, ascii);
|
|
66
|
+
else out += ascii ? "-" : "─";
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function motionIntervalMs(def: MotionDef): number {
|
|
72
|
+
return def.generator?.intervalMs ?? 100;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function motionFrameCount(def: MotionDef): number {
|
|
76
|
+
return framesOf(def).length;
|
|
77
|
+
}
|