@groeponline/pi-wishcraft 1.10.0 → 1.11.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.11.0] - 2026-09-20
6
+
7
+ ### Fixed
8
+ - Deck navigation (reported by Joep): ↑↓ walking the route column is now sticky. Previously ↓ arriving at a list route (Skills, Appearance, Motion, Ideas) was silently consumed by the center list, so walking down the left column appeared to jump sideways into the right pane mid-walk. Focus only crosses columns on an explicit ←/tab (nav) or → (list).
9
+
10
+ ### Changed
11
+ - Motion sweeps traverse on an eased ping-pong instead of a wrapping step: the head decelerates at the rail edges, turns, and glides back — no teleport from the last cell to the first. Each motion's `ease` curve (`linear` / `pulse` / `breathe`) now actually shapes traversal velocity; fractional head positions drive a truecolor trail ramp (hot head cooling to the dim track) replacing the four discrete color steps, and ember/heat heads flicker with the welcome lantern's breathe-plus-ripple curve. Gallery previews render the same eased traversal.
12
+
5
13
  ## [1.10.0] - 2026-09-13
6
14
 
7
15
  ### Fixed
@@ -55,6 +55,13 @@ idle / ready 0 FPS
55
55
 
56
56
  Terminal one-shots (`success`, `warning`, `error`) settle their semantic state back to `idle/ready` when the final frame completes. This prevents a completed agent run from leaving `done` painted indefinitely without keeping a background timer alive.
57
57
 
58
+ Active sweeps traverse the rail on an eased ping-pong: the head decelerates at
59
+ the edges, turns, and glides back instead of teleporting from the last cell to
60
+ the first. Each motion's `ease` curve (`linear`, `pulse`, `breathe`) shapes the
61
+ traversal, the wake cools through a truecolor ramp from the hot head to the
62
+ dim track, and ember/heat heads flicker with the lantern's breathe-plus-ripple
63
+ curve. ASCII terminals keep the plain directional comet.
64
+
58
65
  Reduced/functional/off motion policies may suppress frames entirely; status text must still communicate the state. `NO_COLOR`, screen-reader flags and ASCII fallback are first-class inputs to the same policy.
59
66
 
60
67
  ## Fault isolation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
5
5
  "type": "module",
6
6
  "files": [
@@ -300,11 +300,17 @@ export function createDeckComponent(
300
300
  if (matchesKey(data, "up")) {
301
301
  const next = Math.max(0, state.selectedNav - 1);
302
302
  setRoute(DECK_ROUTE_DEFS[next]?.id ?? state.route);
303
+ // Sticky focus: once ↑↓ drives the nav column it keeps driving the
304
+ // nav column. Without this, ↓ arriving at a list route (skills,
305
+ // appearance, motion, ideas) is silently consumed by the center
306
+ // list and focus appears to jump sideways mid-walk.
307
+ state = { ...state, navMode: true };
303
308
  return;
304
309
  }
305
310
  if (matchesKey(data, "down")) {
306
311
  const next = Math.min(DECK_ROUTE_DEFS.length - 1, state.selectedNav + 1);
307
312
  setRoute(DECK_ROUTE_DEFS[next]?.id ?? state.route);
313
+ state = { ...state, navMode: true };
308
314
  return;
309
315
  }
310
316
 
@@ -60,8 +60,61 @@ export function lanternGlow(nowMs: number): number {
60
60
  return 0.55 * breathe + 0.45 * ripple;
61
61
  }
62
62
 
63
+ export type SweepEase = "linear" | "pulse" | "breathe";
64
+
65
+ /**
66
+ * Eased traversal progress. `linear` keeps a constant velocity, `pulse`
67
+ * accelerates through the middle and settles at the edges, `breathe`
68
+ * dwells longest at the ends before turning. All curves are symmetric so
69
+ * the ping-pong turn at each edge stays continuous — no teleport wrap.
70
+ */
71
+ function easeProgress(s: number, ease: SweepEase): number {
72
+ if (ease === "pulse") return (1 - Math.cos(Math.PI * s)) / 2;
73
+ if (ease === "breathe") {
74
+ // easeInOutQuart: long dwell at the ends, quick through the middle
75
+ return s < 0.5 ? 8 * s * s * s * s : 1 - 8 * (1 - s) ** 4;
76
+ }
77
+ return s;
78
+ }
79
+
80
+ /**
81
+ * Fractional position of a travelling head across `width` cells on a
82
+ * ping-pong traversal (no teleport wrap: the head decelerates, turns at
83
+ * the edge and glides back). Returns -1 when not animating. The fractional
84
+ * part lets color ramps fade between cells while glyphs round to one column.
85
+ */
86
+ export function sweepPhase(
87
+ tick: number,
88
+ width: number,
89
+ animating: boolean,
90
+ direction: "forward" | "reverse" = "forward",
91
+ ease: SweepEase = "linear",
92
+ ): number {
93
+ if (!animating || width <= 1) return animating && width === 1 ? 0 : -1;
94
+ const span = width - 1;
95
+ const period = 2 * span;
96
+ const phase = ((tick % period) + period) % period;
97
+ const s = phase <= span ? phase / span : (period - phase) / span;
98
+ const eased = easeProgress(s, ease) * span;
99
+ return direction === "reverse" ? span - eased : eased;
100
+ }
101
+
102
+ /**
103
+ * True during the return leg of a ping-pong traversal — when the head is
104
+ * travelling back toward its starting edge. Callers orient the trail from
105
+ * this so the wake always trails the head on both legs.
106
+ */
107
+ export function sweepReturning(tick: number, width: number): boolean {
108
+ if (width <= 1) return false;
109
+ const span = width - 1;
110
+ const period = 2 * span;
111
+ const phase = ((tick % period) + period) % period;
112
+ return phase > span;
113
+ }
114
+
63
115
  /**
64
- * Position of a travelling head across `width` cells. Returns -1 when the
116
+ * Position of a travelling head across `width` cells. Ping-pong traversal:
117
+ * the head bounces at the edges instead of wrapping. Returns -1 when the
65
118
  * motion is not animating, so callers can render a still rail.
66
119
  */
67
120
  export function sweepPosition(
@@ -69,10 +122,10 @@ export function sweepPosition(
69
122
  width: number,
70
123
  animating: boolean,
71
124
  direction: "forward" | "reverse" = "forward",
125
+ ease: SweepEase = "linear",
72
126
  ): number {
73
- if (!animating || width <= 0) return -1;
74
- const step = ((tick % width) + width) % width;
75
- return direction === "reverse" ? width - 1 - step : step;
127
+ const phase = sweepPhase(tick, width, animating, direction, ease);
128
+ return phase < 0 ? -1 : Math.round(phase);
76
129
  }
77
130
 
78
131
  /**
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { MOTION_CATALOG } from "./catalog.ts";
7
- import { frameAt, framesOf, sweepPosition, trailGlyph } from "./frames.ts";
7
+ import { frameAt, framesOf, sweepPhase, sweepReturning, trailGlyph } from "./frames.ts";
8
8
  import type { MotionDef } from "./types.ts";
9
9
 
10
10
  export const GALLERY_CATEGORIES = [
@@ -57,13 +57,20 @@ export function previewStrip(
57
57
  const inner = Math.max(8, width);
58
58
  const head = frameAt(def, tick, ascii);
59
59
  const direction = def.generator?.direction ?? "forward";
60
- const pos = sweepPosition(tick, inner, true, direction);
60
+ const ease = def.generator?.ease ?? "linear";
61
+ const trail = def.generator?.trail ?? 2;
62
+ // Same eased ping-pong phase as the status rail, so the gallery previews
63
+ // the real traversal: decelerate, turn, glide back — no teleport wrap.
64
+ // The wake follows the current leg, staying behind the head both ways.
65
+ const pos = sweepPhase(tick, inner, true, direction, ease);
66
+ const movingRight = (direction === "forward") !== sweepReturning(tick, inner);
61
67
  let out = "";
62
68
  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 ? "-" : "─";
69
+ const distance = movingRight ? pos - i : i - pos;
70
+ if (distance > -0.5 && distance <= 0.5) out += head;
71
+ else if (distance > 0.5 && distance <= trail + 0.5) {
72
+ out += trailGlyph(Math.round(distance), ascii);
73
+ } else out += ascii ? "-" : "─";
67
74
  }
68
75
  return out;
69
76
  }
@@ -48,7 +48,9 @@ export {
48
48
  frameAtElapsed,
49
49
  framesOf,
50
50
  lanternGlow,
51
+ sweepPhase,
51
52
  sweepPosition,
53
+ sweepReturning,
52
54
  trailGlyph,
53
55
  } from "./frames.ts";
54
56
 
@@ -5,10 +5,10 @@
5
5
  */
6
6
 
7
7
  import { getMotion } from "../motion/catalog.ts";
8
- import { frameAt, sweepPosition, trailGlyph } from "../motion/frames.ts";
8
+ import { frameAt, lanternGlow, sweepPhase, sweepReturning, trailGlyph } from "../motion/frames.ts";
9
9
  import type { SignalRuntime } from "../signal/controller.ts";
10
10
  import type { SignalSpec } from "../config/types.ts";
11
- import { ansi, colorEnabled, getFgAnsiCode } from "../theme/colors.ts";
11
+ import { ansi, colorEnabled, fgGradientCode, getFgAnsiCode, paletteRgb } from "../theme/colors.ts";
12
12
 
13
13
  function paint(text: string, color: string): string {
14
14
  if (!color || !colorEnabled()) return text;
@@ -21,6 +21,39 @@ function cellGlyph(value: string, fallback: string): string {
21
21
  return Array.from(value)[0] ?? fallback;
22
22
  }
23
23
 
24
+ interface RailPalette {
25
+ /** Truecolor ramp when color is on and glyphs are not ASCII. */
26
+ trueColor: boolean;
27
+ trailDepth: number;
28
+ headRgb: [number, number, number];
29
+ /** Lantern flicker factors; 1 / 0 disable the flicker for non-ember heads. */
30
+ flickerScale: number;
31
+ flickerWhite: number;
32
+ hot: string;
33
+ model: string;
34
+ path: string;
35
+ dim: string;
36
+ }
37
+
38
+ /** Color for a rail cell at `distance` behind the travelling head. */
39
+ function railCellColor(distance: number, p: RailPalette): string {
40
+ if (!p.trueColor) {
41
+ if (distance === 0) return p.hot;
42
+ if (distance === 1) return p.model;
43
+ if (distance === 2) return p.path;
44
+ return p.dim;
45
+ }
46
+ if (distance <= 0.5) {
47
+ const rgb = p.headRgb.map((channel) =>
48
+ Math.min(255, Math.round(channel * p.flickerScale + (255 - channel) * p.flickerWhite)),
49
+ ) as [number, number, number];
50
+ return ansi.getFgAnsi(rgb[0], rgb[1], rgb[2]);
51
+ }
52
+ // Smooth wake: fade the hot head back to the dim track by fractional
53
+ // distance, so trail cells cool off instead of stepping through tiers.
54
+ return fgGradientCode("accent", "sep", Math.min(distance, p.trailDepth + 1) / (p.trailDepth + 1));
55
+ }
56
+
24
57
  export function renderActivity(
25
58
  runtime: SignalRuntime,
26
59
  spec: SignalSpec,
@@ -38,40 +71,38 @@ export function renderActivity(
38
71
  const railWidth = Math.max(12, Math.min(22, Math.round(width * 0.1)));
39
72
  const def = getMotion(runtime.motionId);
40
73
  const direction = def?.generator?.direction === "reverse" ? "reverse" : "forward";
74
+ const ease = def?.generator?.ease ?? "linear";
41
75
  const trailDepth = Math.max(2, Math.min(6, def?.generator?.trail ?? 4));
42
76
  const headFallback = ascii ? "o" : "●";
43
77
  const headGlyph = (tick: number, distance: number) => {
44
78
  if (ascii) return distance === 0 ? headFallback : trailGlyph(distance, true);
45
- return def ? cellGlyph(frameAt(def, tick - distance), headFallback) : headFallback;
79
+ return def ? cellGlyph(frameAt(def, tick - Math.round(distance)), headFallback) : headFallback;
46
80
  };
47
- const cellColor = (distance: number) => {
48
- if (distance === 0) return hot;
49
- if (distance === 1) return model;
50
- if (distance === 2) return path;
51
- return dim;
81
+ // Lantern tie-in: ember/heat heads flicker with the welcome lantern's
82
+ // breathe-plus-ripple curve, so the signature flame lives in the rail.
83
+ const lantern =
84
+ def?.generator?.geometry === "ember" || def?.generator?.geometry === "heat";
85
+ const glow = lantern ? 0.84 + 0.16 * lanternGlow(Date.now()) : 1;
86
+ const palette: RailPalette = {
87
+ trueColor: colorEnabled() && !ascii,
88
+ trailDepth,
89
+ headRgb: paletteRgb("accent"),
90
+ flickerScale: glow,
91
+ flickerWhite: lantern ? 0.09 * ((glow - 0.84) / 0.16) : 0,
92
+ hot,
93
+ model,
94
+ path,
95
+ dim,
52
96
  };
97
+ const cellColor = (distance: number): string => railCellColor(distance, palette);
53
98
 
54
99
  let rail: string;
55
100
  if (!runtime.active) {
56
- // Do not derive idle state from Date.now(): there is intentionally no idle
57
- // animation clock, so a clock-derived rail otherwise changes only when an
58
- // unrelated repaint happens. The center marker makes ready glanceable.
59
- const center = Math.floor(railWidth / 2);
60
- rail = Array.from({ length: railWidth }, (_, index) =>
61
- paint(index === center ? (ascii ? "." : "⋄") : track, dim),
62
- ).join("");
101
+ rail = renderIdleRail(railWidth, ascii, track, dim);
63
102
  } else if (runtime.event === "compact") {
64
103
  rail = renderCompactRail(runtime.tick, railWidth, ascii, headGlyph, cellColor);
65
104
  } else {
66
- const pos = sweepPosition(runtime.tick, railWidth, true, direction);
67
- rail = Array.from({ length: railWidth }, (_, index) => {
68
- const distance = direction === "forward" ? pos - index : index - pos;
69
- if (distance === 0) return paint(headGlyph(runtime.tick, 0), cellColor(0));
70
- if (distance > 0 && distance <= trailDepth) {
71
- return paint(headGlyph(runtime.tick, distance), cellColor(distance));
72
- }
73
- return paint(track, dim);
74
- }).join("");
105
+ rail = renderSweepRail(runtime.tick, railWidth, direction, ease, trailDepth, track, headGlyph, cellColor, dim);
75
106
  }
76
107
 
77
108
  const edge = runtime.active ? hot : dim;
@@ -80,6 +111,51 @@ export function renderActivity(
80
111
  return `${paint(left, edge)}${rail}${paint(right, edge)} ${paint(label, runtime.active ? hot : dim)}`;
81
112
  }
82
113
 
114
+ /** Idle rail. Do not derive idle state from Date.now(): there is
115
+ * intentionally no idle animation clock, so a clock-derived rail otherwise
116
+ * changes only when an unrelated repaint happens. The center marker makes
117
+ * ready glanceable. */
118
+ function renderIdleRail(
119
+ width: number,
120
+ ascii: boolean,
121
+ track: string,
122
+ dim: string,
123
+ ): string {
124
+ const center = Math.floor(width / 2);
125
+ return Array.from({ length: width }, (_, index) =>
126
+ paint(index === center ? (ascii ? "." : "⋄") : track, dim),
127
+ ).join("");
128
+ }
129
+
130
+ /** Eased ping-pong sweep: the head decelerates at the edges and glides back
131
+ * instead of teleporting from the last cell to the first. */
132
+ function renderSweepRail(
133
+ tick: number,
134
+ width: number,
135
+ direction: "forward" | "reverse",
136
+ ease: "linear" | "pulse" | "breathe",
137
+ trailDepth: number,
138
+ track: string,
139
+ headGlyph: (tick: number, distance: number) => string,
140
+ cellColor: (distance: number) => string,
141
+ dim: string,
142
+ ): string {
143
+ const pos = sweepPhase(tick, width, true, direction, ease);
144
+ // Orient the wake by the current leg, not just the configured direction:
145
+ // on the return leg the head travels the other way, so "behind" flips.
146
+ const movingRight = (direction === "forward") !== sweepReturning(tick, width);
147
+ return Array.from({ length: width }, (_, index) => {
148
+ const distance = movingRight ? pos - index : index - pos;
149
+ if (distance > -0.5 && distance <= 0.5) {
150
+ return paint(headGlyph(tick, 0), cellColor(0));
151
+ }
152
+ if (distance > 0.5 && distance <= trailDepth + 0.5) {
153
+ return paint(headGlyph(tick, Math.round(distance)), cellColor(distance));
154
+ }
155
+ return paint(track, dim);
156
+ }).join("");
157
+ }
158
+
83
159
  function renderCompactRail(
84
160
  tick: number,
85
161
  width: number,
@@ -68,3 +68,44 @@ export function getFgAnsiCode(color: ColorName): string {
68
68
  if (!colorEnabled()) return "";
69
69
  return ansiCodeCache.get(color as string) ?? "";
70
70
  }
71
+
72
+ /** Approximate RGB for a 256-color palette code (greyscale + cube). */
73
+ function ansi256ToRgb(code: number): [number, number, number] {
74
+ if (code >= 232) {
75
+ const v = 8 + (code - 232) * 10;
76
+ return [v, v, v];
77
+ }
78
+ if (code >= 16) {
79
+ const c = code - 16;
80
+ const step = [0, 95, 135, 175, 215, 255];
81
+ return [step[Math.floor(c / 36) % 6]!, step[Math.floor(c / 6) % 6]!, step[c % 6]!];
82
+ }
83
+ return [128, 128, 128];
84
+ }
85
+
86
+ export function paletteRgb(color: ColorName): [number, number, number] {
87
+ const val = PALETTE[color];
88
+ if (typeof val === "string" && val.startsWith("#")) return hexToRgb(val);
89
+ if (typeof val === "number") return ansi256ToRgb(val);
90
+ return [128, 128, 128];
91
+ }
92
+
93
+ /**
94
+ * Truecolor gradient between two palette tokens. Returns "" when color is
95
+ * disabled so callers paint plain text, exactly like getFgAnsiCode.
96
+ */
97
+ export function fgGradientCode(
98
+ from: ColorName,
99
+ to: ColorName,
100
+ t: number,
101
+ ): string {
102
+ if (!colorEnabled()) return "";
103
+ const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
104
+ const a = paletteRgb(from);
105
+ const b = paletteRgb(to);
106
+ return ansi.getFgAnsi(
107
+ Math.round(a[0] + (b[0] - a[0]) * clamped),
108
+ Math.round(a[1] + (b[1] - a[1]) * clamped),
109
+ Math.round(a[2] + (b[2] - a[2]) * clamped),
110
+ );
111
+ }