@groeponline/pi-wishcraft 1.9.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 +13 -0
- package/docs/design/signal.md +7 -0
- package/package.json +1 -1
- package/src/extension/ui/deck/component.ts +6 -0
- package/src/motion/frames.ts +57 -4
- package/src/motion/gallery.ts +13 -6
- package/src/motion/index.ts +2 -0
- package/src/render/motion-rail.ts +100 -24
- package/src/theme/colors.ts +41 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
13
|
+
## [1.10.0] - 2026-09-13
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Release bump scan: `chooseBump` now scans every commit subject, so a breaking marker later in the list still promotes to major instead of silently shipping a minor.
|
|
17
|
+
|
|
5
18
|
## [1.9.0] - 2026-09-12
|
|
6
19
|
|
|
7
20
|
### Added
|
package/docs/design/signal.md
CHANGED
|
@@ -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.
|
|
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
|
|
package/src/motion/frames.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
74
|
-
|
|
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
|
/**
|
package/src/motion/gallery.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { MOTION_CATALOG } from "./catalog.ts";
|
|
7
|
-
import { frameAt, framesOf,
|
|
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
|
|
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 =
|
|
64
|
-
if (distance
|
|
65
|
-
else if (distance
|
|
66
|
-
|
|
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
|
}
|
package/src/motion/index.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { getMotion } from "../motion/catalog.ts";
|
|
8
|
-
import { frameAt,
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/theme/colors.ts
CHANGED
|
@@ -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
|
+
}
|