@el4cteo/rbx-studio-mcp 0.3.8 → 0.3.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@el4cteo/rbx-studio-mcp",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "MCP server for Roblox Studio. 29 tools, push-based SSE bridge, editor-safe script edits, one-step undo.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@
9
9
 
10
10
  local Config = {}
11
11
 
12
- Config.PLUGIN_VERSION = "0.3.8"
12
+ Config.PLUGIN_VERSION = "0.3.9"
13
13
 
14
14
  -- Fingerprint of plugin/src, stamped in by scripts/build-plugin.mjs. The server
15
15
  -- computes the same hash from its own copy of the sources and compares, so a
@@ -18,12 +18,49 @@
18
18
  Instances, and colour spans do the same job in a fraction of the code.
19
19
  ]]
20
20
 
21
+ local TweenService = game:GetService("TweenService")
22
+
21
23
  local ThemePicker = require(script.Parent.ThemePicker)
22
24
  local Themes = require(script.Parent.Themes)
23
25
  local Visuals = require(script.Parent.Visuals)
24
26
 
25
27
  local Console = {}
26
28
 
29
+ --[[
30
+ The clear wipe: an old CRT being switched off.
31
+
32
+ A button that empties the screen and leaves no trace is indistinguishable
33
+ from one that crashed the panel. The receipt row answers that in words; this
34
+ answers it in the two hundred milliseconds before anyone has read the words.
35
+
36
+ It is an OVERLAY and nothing else. The log is cleared for real at the same
37
+ instant, underneath, so nothing about the console's state waits on an
38
+ animation -- a line that arrives mid-wipe lands in the fresh log and is
39
+ simply revealed when the overlay goes. What collapses is a still copy of the
40
+ text that was on screen, which is the honest thing to animate: it is a
41
+ picture of what was erased.
42
+
43
+ Three phases, and the timings are the whole effect. The picture holds and
44
+ then whips shut to a line; the line sits a moment; the line whips to a point
45
+ and is gone. Easing into each collapse rather than out of it is what makes it
46
+ read as a tube discharging rather than as a panel being resized.
47
+ ]]
48
+ local CRT_COLLAPSE = 0.16
49
+ local CRT_HOLD = 0.07
50
+ local CRT_BLINK = 0.17
51
+ local CRT_SHUT = TweenInfo.new(CRT_COLLAPSE, Enum.EasingStyle.Quart, Enum.EasingDirection.In)
52
+ local CRT_SETTLE = TweenInfo.new(0.05, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
53
+ local CRT_OUT = TweenInfo.new(CRT_BLINK, Enum.EasingStyle.Quart, Enum.EasingDirection.In)
54
+
55
+ --[[
56
+ Explicit depths, because a plugin widget draws with ZIndexBehavior.Global
57
+ and a child does not inherit its parent's. Above the log, the band and the
58
+ hover readout; below the preset drawer, which must stay clickable.
59
+ ]]
60
+ local Z_CRT = 20
61
+ local Z_CRT_TEXT = 21
62
+ local Z_CRT_LINE = 22
63
+
27
64
  --[[
28
65
  Ring buffer bound, counted in logged rows rather than in rendered lines.
29
66
 
@@ -209,6 +246,24 @@ type State = {
209
246
  clients: number,
210
247
  -- Set while a repaint is already scheduled for the end of this frame.
211
248
  dirty: boolean,
249
+
250
+ --[[
251
+ The clear wipe's overlay: an unclipped shell over the log region, the
252
+ collapsing screen inside it, a fixed window holding the still copy of
253
+ the text, and the bright line the picture discharges into.
254
+ ]]
255
+ crt: Frame?,
256
+ crtScreen: Frame?,
257
+ crtWindow: Frame?,
258
+ crtGhost: TextLabel?,
259
+ crtLine: Frame?,
260
+ --[[
261
+ Bumped by every wipe, so the delayed halves of an earlier one know they
262
+ have been superseded and do nothing. `task.delay` hands back no handle to
263
+ cancel, and clearing twice inside four hundred milliseconds is a click
264
+ away.
265
+ ]]
266
+ crtGeneration: number,
212
267
  }
213
268
 
214
269
  local state: State = {
@@ -232,6 +287,12 @@ local state: State = {
232
287
  generation = 0,
233
288
  clients = 1,
234
289
  dirty = false,
290
+ crt = nil,
291
+ crtScreen = nil,
292
+ crtWindow = nil,
293
+ crtGhost = nil,
294
+ crtLine = nil,
295
+ crtGeneration = 0,
235
296
  }
236
297
 
237
298
  -- How long the session must be silent before the console says so. Long enough
@@ -656,7 +717,121 @@ function Console.setClients(count: number)
656
717
  end
657
718
  end
658
719
 
720
+ --[[
721
+ Plays the wipe over whatever is on screen right now.
722
+
723
+ Must be called BEFORE the log is emptied: the still copy it collapses is
724
+ read straight off the live label, scroll offset and all, so what discharges
725
+ is exactly the text the user was looking at rather than a re-render of it.
726
+
727
+ Silent when there is nothing to play it on -- a widget dragged down to a
728
+ sliver has no room for a picture to collapse, and a flourish that plays in
729
+ four pixels is a flicker, not an effect.
730
+ ]]
731
+ local function crtWipe()
732
+ local shell = state.crt
733
+ local screen = state.crtScreen
734
+ local window = state.crtWindow
735
+ local ghost = state.crtGhost
736
+ local line = state.crtLine
737
+ local scroller = state.scroller
738
+ local label = state.label
739
+ if
740
+ shell == nil
741
+ or screen == nil
742
+ or window == nil
743
+ or ghost == nil
744
+ or line == nil
745
+ or scroller == nil
746
+ or label == nil
747
+ then
748
+ return
749
+ end
750
+
751
+ local height = scroller.AbsoluteSize.Y
752
+ if height < 24 then
753
+ return
754
+ end
755
+
756
+ state.crtGeneration += 1
757
+ local generation = state.crtGeneration
758
+
759
+ -- Read at play time rather than tracked, so the overlay lands on the log
760
+ -- wherever the band toggle and the widget's size have left it.
761
+ shell.Position = scroller.Position
762
+ shell.Size = scroller.Size
763
+
764
+ --[[
765
+ The still copy, offset by exactly as far as the log is scrolled.
766
+
767
+ A TextLabel cannot scroll its own text, so the window is a fixed-height
768
+ clip and the label inside it is pushed up by the canvas offset. That is
769
+ what makes the copy line up with the real log to the pixel instead of
770
+ snapping to the top of the buffer the moment the wipe starts.
771
+ ]]
772
+ window.Size = UDim2.new(1, 0, 0, height)
773
+ ghost.Position = UDim2.new(0, 12, 0, 8 - scroller.CanvasPosition.Y)
774
+ ghost.Size = UDim2.new(1, -24, 0, 0)
775
+ ghost.TextColor3 = PALETTE.text
776
+ ghost.Text = label.Text
777
+
778
+ screen.BackgroundColor3 = PALETTE.background
779
+ screen.Size = UDim2.new(1, 0, 1, 0)
780
+ screen.Visible = true
781
+
782
+ line.BackgroundColor3 = PALETTE.text
783
+ line.Size = UDim2.new(1, 0, 0, 2)
784
+ line.BackgroundTransparency = 1
785
+ line.Visible = false
786
+
787
+ shell.Visible = true
788
+
789
+ -- The picture, whipping shut around its own middle.
790
+ TweenService:Create(screen, CRT_SHUT, { Size = UDim2.new(1, 0, 0, 2) }):Play()
791
+
792
+ task.delay(CRT_COLLAPSE, function()
793
+ if state.crtGeneration ~= generation then
794
+ return
795
+ end
796
+
797
+ --[[
798
+ The handover. The screen goes and the line arrives in the same frame,
799
+ a little over-thick, and settles -- which is the flash. Fading one
800
+ into the other instead just looks like a crossfade of two rectangles.
801
+ ]]
802
+ screen.Visible = false
803
+ line.BackgroundTransparency = 0
804
+ line.Size = UDim2.new(1, 0, 0, 3)
805
+ line.Visible = true
806
+ TweenService:Create(line, CRT_SETTLE, { Size = UDim2.new(1, 0, 0, 2) }):Play()
807
+
808
+ task.delay(CRT_HOLD, function()
809
+ if state.crtGeneration ~= generation then
810
+ return
811
+ end
812
+
813
+ TweenService:Create(line, CRT_OUT, {
814
+ Size = UDim2.new(0, 0, 0, 2),
815
+ BackgroundTransparency = 0.35,
816
+ }):Play()
817
+
818
+ task.delay(CRT_BLINK, function()
819
+ if state.crtGeneration ~= generation then
820
+ return
821
+ end
822
+ shell.Visible = false
823
+ line.Visible = false
824
+ screen.Visible = true
825
+ end)
826
+ end)
827
+ end)
828
+ end
829
+
659
830
  function Console.clear()
831
+ -- Before anything is emptied: the wipe collapses a copy of what is on
832
+ -- screen, and a moment later there is nothing on screen to copy.
833
+ crtWipe()
834
+
660
835
  --[[
661
836
  Says what it did, with a timestamp, like everything else in here.
662
837
 
@@ -691,6 +866,9 @@ end
691
866
  visible without scrolling, however long the session has run.
692
867
  ]]
693
868
  function Console.setStatus(status: string, meta: string)
869
+ -- Read before it is overwritten: the arrival flourish below is the one thing
870
+ -- here that cares whether this is a change or a repeat.
871
+ local previous = state.status
694
872
  state.status = status
695
873
  state.statusMeta = meta
696
874
 
@@ -718,6 +896,23 @@ function Console.setStatus(status: string, meta: string)
718
896
  -- The wireframe takes the same colour as the status light, so the panel
719
897
  -- reads as disconnected at a glance even with the header off screen.
720
898
  Visuals.setTint(color)
899
+
900
+ -- The trace has no calls to plot until a session exists, so it waves while
901
+ -- the transport is reaching for one rather than sitting as a dead baseline
902
+ -- at exactly the moment somebody is watching to see whether it is alive.
903
+ Visuals.setConnecting(status == "connecting")
904
+
905
+ --[[
906
+ And it celebrates when the session finally lands.
907
+
908
+ Only on the transition. `setStatus` is also how a theme switch and a
909
+ port change repaint the header, and both replay the current status --
910
+ which would fire the flourish again for an event that already happened,
911
+ several times a session, until it read as noise rather than as news.
912
+ ]]
913
+ if status == "connected" and previous ~= "connected" then
914
+ Visuals.celebrate()
915
+ end
721
916
  end
722
917
 
723
918
  --[[
@@ -958,6 +1153,84 @@ function Console.mount(parent: Instance, handlers: Handlers)
958
1153
  label.Parent = scroller
959
1154
  state.label = label
960
1155
 
1156
+ --[[
1157
+ The clear wipe's overlay, built once and hidden.
1158
+
1159
+ Four nested pieces, and each one earns its place. `crt` is unclipped and
1160
+ covers the log region, so the line can stay full width while the screen
1161
+ inside it closes. `screen` is the clip that actually collapses, anchored
1162
+ to its own middle so it shuts toward the centre rather than rolling up
1163
+ from the top. `window` is a fixed-height clip that does not move with it,
1164
+ which is what holds the copied text still while the screen closes over
1165
+ it. `ghost` is the copy.
1166
+ ]]
1167
+ local crt = Instance.new("Frame")
1168
+ crt.Name = "ClearWipe"
1169
+ crt.BackgroundTransparency = 1
1170
+ crt.BorderSizePixel = 0
1171
+ crt.Visible = false
1172
+ crt.ZIndex = Z_CRT
1173
+ crt.Parent = root
1174
+ state.crt = crt
1175
+
1176
+ local crtScreen = Instance.new("Frame")
1177
+ crtScreen.Name = "Screen"
1178
+ crtScreen.AnchorPoint = Vector2.new(0.5, 0.5)
1179
+ crtScreen.Position = UDim2.fromScale(0.5, 0.5)
1180
+ crtScreen.Size = UDim2.new(1, 0, 1, 0)
1181
+ themed(crtScreen, "BackgroundColor3", "background")
1182
+ crtScreen.BorderSizePixel = 0
1183
+ crtScreen.ClipsDescendants = true
1184
+ crtScreen.ZIndex = Z_CRT
1185
+ crtScreen.Parent = crt
1186
+ state.crtScreen = crtScreen
1187
+
1188
+ local crtWindow = Instance.new("Frame")
1189
+ crtWindow.Name = "Window"
1190
+ crtWindow.AnchorPoint = Vector2.new(0.5, 0.5)
1191
+ crtWindow.Position = UDim2.fromScale(0.5, 0.5)
1192
+ crtWindow.Size = UDim2.new(1, 0, 1, 0)
1193
+ crtWindow.BackgroundTransparency = 1
1194
+ crtWindow.BorderSizePixel = 0
1195
+ crtWindow.ClipsDescendants = true
1196
+ crtWindow.ZIndex = Z_CRT_TEXT
1197
+ crtWindow.Parent = crtScreen
1198
+ state.crtWindow = crtWindow
1199
+
1200
+ -- Every text property the log's own label has, because the copy has to be
1201
+ -- indistinguishable from it for the frame before it starts moving.
1202
+ local crtGhost = Instance.new("TextLabel")
1203
+ crtGhost.Name = "Ghost"
1204
+ crtGhost.BackgroundTransparency = 1
1205
+ crtGhost.Font = Enum.Font.Code
1206
+ crtGhost.TextSize = 12
1207
+ crtGhost.LineHeight = 1.25
1208
+ themed(crtGhost, "TextColor3", "text")
1209
+ crtGhost.RichText = true
1210
+ crtGhost.TextWrapped = true
1211
+ crtGhost.AutomaticSize = Enum.AutomaticSize.Y
1212
+ crtGhost.Size = UDim2.new(1, -24, 0, 0)
1213
+ crtGhost.TextXAlignment = Enum.TextXAlignment.Left
1214
+ crtGhost.TextYAlignment = Enum.TextYAlignment.Top
1215
+ crtGhost.Text = ""
1216
+ crtGhost.ZIndex = Z_CRT_TEXT
1217
+ crtGhost.Parent = crtWindow
1218
+ state.crtGhost = crtGhost
1219
+
1220
+ -- The line the picture discharges into. Outside `screen`, so it keeps its
1221
+ -- width while the screen closes to nothing behind it.
1222
+ local crtLine = Instance.new("Frame")
1223
+ crtLine.Name = "Line"
1224
+ crtLine.AnchorPoint = Vector2.new(0.5, 0.5)
1225
+ crtLine.Position = UDim2.fromScale(0.5, 0.5)
1226
+ crtLine.Size = UDim2.new(1, 0, 0, 2)
1227
+ themed(crtLine, "BackgroundColor3", "text")
1228
+ crtLine.BorderSizePixel = 0
1229
+ crtLine.Visible = false
1230
+ crtLine.ZIndex = Z_CRT_LINE
1231
+ crtLine.Parent = crt
1232
+ state.crtLine = crtLine
1233
+
961
1234
  -- Status bar ------------------------------------------------------------
962
1235
  local footer = Instance.new("Frame")
963
1236
  footer.AnchorPoint = Vector2.new(0, 1)
@@ -65,6 +65,87 @@ local BAR_STRIDE = 5
65
65
  -- its extra pixels here: a 19px column was a small thing to aim a pointer at.
66
66
  local TRACE_HEIGHT = 26
67
67
 
68
+ --[[
69
+ The connecting wave: what the trace does while there is no session to plot.
70
+
71
+ A connect or a reconnect is the one moment the trace has nothing true to
72
+ say. No calls have happened yet, so it is a bare baseline -- which looks
73
+ exactly like a panel that has died, at the one time the user is most likely
74
+ to be watching it to find out whether it has.
75
+
76
+ So the columns wave instead: a swell travelling along the trace for as long
77
+ as the transport is reaching for the bridge, and gone the moment it either
78
+ arrives or gives up.
79
+
80
+ It is drawn THROUGH THE ACTIVE PRESET's slot painter rather than painted
81
+ here, for the same reason history is. Eight presets already know how to draw
82
+ a column of a given height and freshness, so routing the wave through them
83
+ means it arrives in all eight without eight implementations of it -- a theme
84
+ that draws history as rising points draws the wave as an arc of points, and
85
+ nobody had to tell it to.
86
+ ]]
87
+ -- Columns in the wave. Fixed, and positioned by scale rather than by stride, so
88
+ -- the wave spans the trace at any panel width off a pool built once at mount.
89
+ local WAVE_COLUMNS = 48
90
+ -- Seconds for one swell to cross the trace.
91
+ local WAVE_PERIOD = 1.8
92
+ -- The swell's width as a fraction of the trace. Narrow enough to read as a
93
+ -- pulse travelling along it rather than as the whole trace breathing.
94
+ local WAVE_SPREAD = 0.2
95
+ -- A finer ripple riding on the swell, in cycles across the trace, so the crest
96
+ -- reads as strips moving rather than as one solid hump sliding past.
97
+ local WAVE_RIPPLE = 2.5
98
+ -- Fade rates, in and out. Out is the slower of the two: an attempt that fails
99
+ -- immediately should ebb rather than blink off.
100
+ local WAVE_IN = 7
101
+ local WAVE_OUT = 2.5
102
+ --[[
103
+ The shortest time the wave stays up once it has started.
104
+
105
+ A handshake against a port with nothing behind it fails in milliseconds, and
106
+ the transport reports "connecting" before every one of those attempts. Without
107
+ a floor the wave would be a single-frame flash per retry, which reads as a
108
+ glitch rather than as an attempt being made.
109
+ ]]
110
+ local WAVE_HOLD = 0.7
111
+ -- Where the columns rest when the wave is not lifting them. Low enough to read
112
+ -- as an instrument waiting rather than as invented data, high enough to be
113
+ -- there at all.
114
+ local WAVE_FLOOR = 0.05
115
+
116
+ --[[
117
+ The arrival: what the trace does the moment a session actually exists.
118
+
119
+ The connecting wave is a searching motion and says so -- the same swell over
120
+ and over, going nowhere. Landing deserves a different shape, once, or the
121
+ only thing that marks the event is a word in the header going green.
122
+
123
+ So the columns are struck rather than swept. A bright front races along the
124
+ trace and every column it reaches leaps and rings down behind it, which
125
+ reads as a thing arriving rather than a thing looking. It plays once per
126
+ connection and cannot repeat on its own -- the status line only fires it on
127
+ a real transition into `connected`, so a theme switch or a port change
128
+ replaying the same status does not replay the celebration.
129
+ ]]
130
+ -- How long the whole flourish lasts.
131
+ local CHEER_TIME = 1.4
132
+ -- The fraction of that time the front takes to cross the trace. Well under
133
+ -- half: the sweep should outrun the ringing rather than drag it along.
134
+ local CHEER_SWEEP = 0.38
135
+ -- How fast a struck column settles. Against the sweep above this leaves about
136
+ -- two visible bounces before it is back on the baseline.
137
+ local CHEER_DECAY = 3.2
138
+ -- Bounces per column, over the flourish's own length.
139
+ local CHEER_RING = 2.5
140
+ --[[
141
+ How abruptly the flourish fades.
142
+
143
+ Opacity holds at full for the first stretch and only gives way at the end,
144
+ so the celebration reads as ringing out rather than as being dimmed while it
145
+ is still moving. `1 / CHEER_FADE` is the fraction of the tail spent fading.
146
+ ]]
147
+ local CHEER_FADE = 2.5
148
+
68
149
  --[[
69
150
  The strip's height, and the geometry around the cell.
70
151
 
@@ -146,6 +227,22 @@ type Runtime = {
146
227
  lastTitle: string?,
147
228
  -- Whether the active preset has been given its Instances yet.
148
229
  mounted: boolean,
230
+
231
+ -- The wave's columns, pooled at mount and hidden until a connect needs them.
232
+ wave: { Frame },
233
+ -- What the transport last said: true while it is trying to reach the bridge.
234
+ waveWanted: boolean,
235
+ -- The ramp, 0 to 1. Separate from `waveWanted` so the wave fades rather than
236
+ -- appearing and vanishing with the status line.
237
+ waveLevel: number,
238
+ -- Seconds the wave is held up regardless of `waveWanted`. See WAVE_HOLD.
239
+ waveHold: number,
240
+ -- Whether the columns are currently on screen. Kept rather than derived, so
241
+ -- a settled wave hides them once instead of every frame afterwards.
242
+ waveShown: boolean,
243
+ -- The arrival flourish: 1 the instant a session connects, down to 0 over
244
+ -- CHEER_TIME. Overrides the connecting wave's shape while it runs.
245
+ cheer: number,
149
246
  }
150
247
 
151
248
  local runtime: Runtime = {
@@ -174,6 +271,12 @@ local runtime: Runtime = {
174
271
  activity = 0,
175
272
  lastTitle = nil,
176
273
  mounted = false,
274
+ wave = {},
275
+ waveWanted = false,
276
+ waveLevel = 0,
277
+ waveHold = 0,
278
+ waveShown = false,
279
+ cheer = 0,
177
280
  }
178
281
 
179
282
  --[[
@@ -201,6 +304,10 @@ local function context(): Themes.Context
201
304
  }
202
305
  end
203
306
 
307
+ -- Declared here and defined below, because it draws through the same slot
308
+ -- painter history does and that helper is defined after this function.
309
+ local stepWave: (delta: number) -> ()
310
+
204
311
  local function step(delta: number)
205
312
  local band = runtime.band
206
313
  local cell = runtime.cell
@@ -314,6 +421,10 @@ local function step(delta: number)
314
421
  end
315
422
  end
316
423
 
424
+ -- Stepped before the cell is handed off, so a preset that errors and
425
+ -- disables the strip does not also take the connect animation with it.
426
+ stepWave(delta)
427
+
317
428
  --[[
318
429
  Handed off. A preset that throws must not take the console's status
319
430
  display down with it, so a failure here disables the strip and says so
@@ -396,6 +507,171 @@ local function defaultSlot(slot: Themes.Slot, ctx: Themes.Context)
396
507
  frame.Rotation = 0
397
508
  end
398
509
 
510
+ --[[
511
+ Runs the connecting wave for one frame.
512
+
513
+ Cheap when nothing is connecting: the ramp settles at zero, the columns are
514
+ hidden once, and every later frame leaves after two comparisons.
515
+ ]]
516
+ function stepWave(delta: number)
517
+ if #runtime.wave == 0 then
518
+ return
519
+ end
520
+
521
+ runtime.cheer = math.max(0, runtime.cheer - delta / CHEER_TIME)
522
+
523
+ runtime.waveHold = math.max(0, runtime.waveHold - delta)
524
+ local target = if runtime.waveWanted or runtime.waveHold > 0 then 1 else 0
525
+ local rate = if target > runtime.waveLevel then WAVE_IN else WAVE_OUT
526
+ runtime.waveLevel += (target - runtime.waveLevel) * math.min(1, delta * rate)
527
+
528
+ --[[
529
+ Two sources, one opacity.
530
+
531
+ They overlap for a moment at every connection -- the status goes green,
532
+ which drops `waveWanted`, while the flourish is only just starting -- and
533
+ taking the louder of the two is what carries the handover without a dip
534
+ between the search ending and the arrival beginning.
535
+ ]]
536
+ local celebration = math.min(1, runtime.cheer * CHEER_FADE)
537
+ local strength = math.max(runtime.waveLevel, celebration)
538
+
539
+ if strength <= 0.01 then
540
+ -- Hidden once on the way down rather than every frame afterwards, so a
541
+ -- connected session pays nothing for an animation it is not running.
542
+ if runtime.waveShown then
543
+ runtime.waveShown = false
544
+ runtime.waveLevel = 0
545
+ for _, column in runtime.wave do
546
+ column.Visible = false
547
+ end
548
+ end
549
+ return
550
+ end
551
+ runtime.waveShown = true
552
+
553
+ local ctx = context()
554
+ local painter = Themes.active().paintSlot or defaultSlot
555
+ local phase = ctx.clock / WAVE_PERIOD
556
+ local crest = phase % 1
557
+ -- The flourish runs on its own progress rather than on the clock, so it is
558
+ -- the same length however long the connection took to land.
559
+ local arriving = runtime.cheer > 0
560
+ local progress = 1 - runtime.cheer
561
+
562
+ for index, column in runtime.wave do
563
+ -- 0 at the left edge of the trace, 1 at the right.
564
+ local x = (index - 0.5) / WAVE_COLUMNS
565
+
566
+ local weight: number
567
+ local age: number
568
+
569
+ if arriving then
570
+ --[[
571
+ Struck, not swept. `since` is how long ago the front passed this
572
+ column: negative means it has not arrived, and the column waits
573
+ flat and dark rather than anticipating it.
574
+ ]]
575
+ local since = progress - x * CHEER_SWEEP
576
+ if since < 0 then
577
+ weight = WAVE_FLOOR
578
+ age = 1
579
+ else
580
+ -- One envelope for both height and brightness, so a column is
581
+ -- at its tallest exactly when it is at its brightest.
582
+ local settle = math.exp(-since * CHEER_DECAY)
583
+ local bounce = 0.5 + 0.5 * math.cos(since * CHEER_RING * math.pi * 2)
584
+ weight = math.clamp(WAVE_FLOOR + settle * bounce * 0.95, WAVE_FLOOR, 1)
585
+ age = math.clamp(1 - settle, 0, 1)
586
+ end
587
+ else
588
+ --[[
589
+ Distance to the crest, wrapped around the ends, so the swell
590
+ leaves the right edge and re-enters at the left instead of
591
+ jumping back across a trace it has just crossed.
592
+ ]]
593
+ local distance = math.abs(x - crest)
594
+ distance = math.min(distance, 1 - distance)
595
+ local swell = math.exp(-(distance * distance) / (2 * WAVE_SPREAD * WAVE_SPREAD))
596
+ local ripple = 0.5 + 0.5 * math.sin((x * WAVE_RIPPLE - phase * 2) * math.pi * 2)
597
+
598
+ --[[
599
+ A floor everywhere and a hump at the crest. Away from the swell
600
+ the columns sit just off the baseline, which keeps the trace
601
+ reading as an instrument at rest rather than as forty bars of
602
+ invented data.
603
+ ]]
604
+ weight = math.clamp(WAVE_FLOOR + swell * (0.55 + 0.45 * ripple) * 0.9, WAVE_FLOOR, 1)
605
+ --[[
606
+ Freshness follows the swell rather than position. Every preset
607
+ fades a slot by how recent it is, so handing the crest an age of
608
+ zero lights it and leaves the rest of the trace resting -- one
609
+ number, and eight presets brighten the same part of the wave.
610
+ ]]
611
+ age = math.clamp(1 - swell, 0, 1)
612
+ end
613
+
614
+ local slot: Themes.Slot = {
615
+ frame = column,
616
+ -- Kept agreeing with the height, since that is the pair every
617
+ -- painter is written against, even though none of them reads it.
618
+ milliseconds = weight * SLOW_MS,
619
+ ok = true,
620
+ title = if arriving then "connected" else "connecting",
621
+ age = age,
622
+ weight = weight,
623
+ }
624
+
625
+ -- Set before the painter runs: every painter preserves the X it is
626
+ -- given and decides the rest.
627
+ column.Position = UDim2.new(x, 0, 1, 0)
628
+ if not pcall(painter, slot, ctx) then
629
+ -- Silent, unlike the history path. A preset that throws here would
630
+ -- throw sixty times a second, and `relayout` will have said so once
631
+ -- already the next time a call lands.
632
+ defaultSlot(slot, ctx)
633
+ end
634
+
635
+ --[[
636
+ Faded as one thing, after whoever drew it. The ramp belongs to the
637
+ wave and not to any preset, and applying it to the transparency the
638
+ painter chose keeps each theme's own weighting intact.
639
+ ]]
640
+ column.BackgroundTransparency = 1 - (1 - column.BackgroundTransparency) * strength
641
+ column.Visible = true
642
+ end
643
+ end
644
+
645
+ --[[
646
+ Whether the transport is currently reaching for the bridge.
647
+
648
+ Told rather than inferred, because "connecting" is the transport's word and
649
+ the trace must never disagree with the status dot about what the session is
650
+ doing.
651
+ ]]
652
+ function Visuals.setConnecting(active: boolean)
653
+ if active and not runtime.waveWanted then
654
+ runtime.waveHold = WAVE_HOLD
655
+ end
656
+ runtime.waveWanted = active
657
+ end
658
+
659
+ --[[
660
+ Plays the arrival flourish, once.
661
+
662
+ Edge-triggered by the caller rather than latched here: only the status line
663
+ knows whether this is a session actually landing or the same `connected`
664
+ state being repeated after a theme switch, and a celebration that replays
665
+ itself every time the port is re-read stops meaning anything.
666
+ ]]
667
+ function Visuals.celebrate()
668
+ runtime.cheer = 1
669
+ -- The search is over. Nothing should be left holding the connecting wave up
670
+ -- underneath a flourish that has replaced it.
671
+ runtime.waveWanted = false
672
+ runtime.waveHold = 0
673
+ end
674
+
399
675
  --[[
400
676
  Re-places every bar and hands each to the active preset to draw.
401
677
 
@@ -775,6 +1051,34 @@ function Visuals.mount(parent: Instance)
775
1051
  baseline.Parent = trace
776
1052
  runtime.baseline = baseline
777
1053
 
1054
+ --[[
1055
+ The connecting wave's columns, built once and hidden until an attempt
1056
+ needs them.
1057
+
1058
+ Created here -- before the highlight and long before any bar -- because
1059
+ draw order among siblings is creation order, and the wave must sit under
1060
+ both. It is what the trace does while it has no history; it must never
1061
+ be drawn over the history it was standing in for.
1062
+ ]]
1063
+ for index = 1, WAVE_COLUMNS do
1064
+ local column = Instance.new("Frame")
1065
+ column.Name = "Wave"
1066
+ column.AnchorPoint = Vector2.new(0.5, 1)
1067
+ column.Position = UDim2.new((index - 0.5) / WAVE_COLUMNS, 0, 1, 0)
1068
+ column.Size = UDim2.fromOffset(2, 1)
1069
+ column.BackgroundColor3 = palette.violet
1070
+ column.BorderSizePixel = 0
1071
+ column.Visible = false
1072
+ column.Parent = trace
1073
+
1074
+ -- Presets draw points as well as bars, same as the history slots.
1075
+ local round = Instance.new("UICorner")
1076
+ round.CornerRadius = UDim.new(0, 1)
1077
+ round.Parent = column
1078
+
1079
+ runtime.wave[index] = column
1080
+ end
1081
+
778
1082
  --[[
779
1083
  Built once and moved, and created BEFORE the bars exist so it sits under
780
1084
  them in draw order -- a highlight drawn over a one-pixel bar would hide the