@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/tools/api.js +20 -1
  6. package/dist/tools/api.js.map +1 -1
  7. package/dist/tools/debug.js +18 -8
  8. package/dist/tools/debug.js.map +1 -1
  9. package/dist/tools/discover.js +5 -0
  10. package/dist/tools/discover.js.map +1 -1
  11. package/dist/tools/input.js +38 -5
  12. package/dist/tools/input.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +9 -8
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +10 -1
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/package.json +1 -1
  20. package/plugin/src/Config.luau +1 -1
  21. package/plugin/src/Console.luau +301 -117
  22. package/plugin/src/Mirror.luau +126 -0
  23. package/plugin/src/Serialize.luau +43 -3
  24. package/plugin/src/ThemePicker.luau +458 -0
  25. package/plugin/src/Themes/Aurora.luau +161 -0
  26. package/plugin/src/Themes/Blueprint.luau +213 -0
  27. package/plugin/src/Themes/Draw.luau +177 -0
  28. package/plugin/src/Themes/Lattice.luau +304 -0
  29. package/plugin/src/Themes/Nebula.luau +182 -0
  30. package/plugin/src/Themes/Observatory.luau +200 -0
  31. package/plugin/src/Themes/Orbit.luau +268 -0
  32. package/plugin/src/Themes/Phosphor.luau +200 -0
  33. package/plugin/src/Themes/Theme.luau +121 -0
  34. package/plugin/src/Themes/Void.luau +266 -0
  35. package/plugin/src/Themes/init.luau +116 -0
  36. package/plugin/src/Undo.luau +29 -0
  37. package/plugin/src/Visuals.luau +838 -907
  38. package/plugin/src/handlers/Device.luau +21 -1
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Input.luau +583 -493
  42. package/plugin/src/handlers/Instances.luau +35 -3
  43. package/plugin/src/init.server.luau +209 -11
@@ -0,0 +1,304 @@
1
+ --!strict
2
+ --[[
3
+ Lattice -- the default.
4
+
5
+ A turning wireframe solid with a counter-rotating core, over a bar trace of
6
+ the last forty calls. This is the console's original look, and it stays the
7
+ default because it is the most legible of the eight: hard edges, one accent
8
+ colour, nothing moving that is not reporting something.
9
+
10
+ The solid is a real projection rather than a canned loop. Vertices rotate in
11
+ three dimensions and pass through a perspective divide every frame, so near
12
+ edges are brighter and thicker because they genuinely are nearer, and the
13
+ shape climbs a ladder -- tetrahedron, octahedron, cube, icosahedron -- as the
14
+ session gets busier. An idle plugin shows four vertices; a working one shows
15
+ twelve. The shape itself is the load meter.
16
+ ]]
17
+
18
+ local Draw = require(script.Parent.Draw)
19
+ local Theme = require(script.Parent.Theme)
20
+
21
+ local PHI = (1 + math.sqrt(5)) / 2
22
+
23
+ type Shape = {
24
+ name: string,
25
+ vertices: { Vector3 },
26
+ edges: { { number } },
27
+ }
28
+
29
+ --[[
30
+ Ordered by how busy they look. The session climbs this list as it works, so
31
+ the shape itself reports load: an idle plugin shows a tetrahedron, a busy one
32
+ an icosahedron.
33
+ ]]
34
+ local SHAPES: { Shape } = {
35
+ {
36
+ name = "tetrahedron",
37
+ vertices = {
38
+ Vector3.new(1, 1, 1),
39
+ Vector3.new(1, -1, -1),
40
+ Vector3.new(-1, 1, -1),
41
+ Vector3.new(-1, -1, 1),
42
+ },
43
+ edges = { { 1, 2 }, { 1, 3 }, { 1, 4 }, { 2, 3 }, { 2, 4 }, { 3, 4 } },
44
+ },
45
+ {
46
+ name = "octahedron",
47
+ vertices = {
48
+ Vector3.new(1, 0, 0),
49
+ Vector3.new(-1, 0, 0),
50
+ Vector3.new(0, 1, 0),
51
+ Vector3.new(0, -1, 0),
52
+ Vector3.new(0, 0, 1),
53
+ Vector3.new(0, 0, -1),
54
+ },
55
+ edges = {
56
+ { 1, 3 }, { 1, 4 }, { 1, 5 }, { 1, 6 },
57
+ { 2, 3 }, { 2, 4 }, { 2, 5 }, { 2, 6 },
58
+ { 3, 5 }, { 5, 4 }, { 4, 6 }, { 6, 3 },
59
+ },
60
+ },
61
+ {
62
+ name = "cube",
63
+ vertices = {
64
+ Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1),
65
+ Vector3.new(1, 1, -1), Vector3.new(-1, 1, -1),
66
+ Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1),
67
+ Vector3.new(1, 1, 1), Vector3.new(-1, 1, 1),
68
+ },
69
+ edges = {
70
+ { 1, 2 }, { 2, 3 }, { 3, 4 }, { 4, 1 },
71
+ { 5, 6 }, { 6, 7 }, { 7, 8 }, { 8, 5 },
72
+ { 1, 5 }, { 2, 6 }, { 3, 7 }, { 4, 8 },
73
+ },
74
+ },
75
+ {
76
+ name = "icosahedron",
77
+ vertices = {
78
+ Vector3.new(0, 1, PHI), Vector3.new(0, -1, PHI),
79
+ Vector3.new(0, 1, -PHI), Vector3.new(0, -1, -PHI),
80
+ Vector3.new(1, PHI, 0), Vector3.new(-1, PHI, 0),
81
+ Vector3.new(1, -PHI, 0), Vector3.new(-1, -PHI, 0),
82
+ Vector3.new(PHI, 0, 1), Vector3.new(PHI, 0, -1),
83
+ Vector3.new(-PHI, 0, 1), Vector3.new(-PHI, 0, -1),
84
+ },
85
+ edges = {
86
+ { 1, 2 }, { 1, 5 }, { 1, 6 }, { 1, 9 }, { 1, 11 },
87
+ { 2, 7 }, { 2, 8 }, { 2, 9 }, { 2, 11 },
88
+ { 3, 4 }, { 3, 5 }, { 3, 6 }, { 3, 10 }, { 3, 12 },
89
+ { 4, 7 }, { 4, 8 }, { 4, 10 }, { 4, 12 },
90
+ { 5, 6 }, { 5, 9 }, { 5, 10 },
91
+ { 6, 11 }, { 6, 12 },
92
+ { 7, 8 }, { 7, 9 }, { 7, 10 },
93
+ { 8, 11 }, { 8, 12 },
94
+ { 9, 10 }, { 11, 12 },
95
+ },
96
+ },
97
+ }
98
+
99
+ local MAX_EDGES = 30
100
+ local MAX_VERTICES = 12
101
+ local FOCAL = 3.4
102
+
103
+ -- The inner solid, counter-rotating inside the outer one. An octahedron
104
+ -- regardless of what is outside it: it reads as a core rather than as a second
105
+ -- object competing for attention, and its 12 edges are cheap.
106
+ local INNER_SHAPE = 2
107
+ local INNER_SCALE = 0.42
108
+
109
+ -- The solid's resting radius as a fraction of the cell. At full overshoot the
110
+ -- perspective divide pushes a near vertex to roughly 15px, which still clears
111
+ -- the cell's half-width.
112
+ local BASE_RADIUS = 0.185
113
+
114
+ local Lattice = {}
115
+
116
+ Lattice.id = "lattice"
117
+ Lattice.label = "Lattice"
118
+ Lattice.blurb = "Wireframe solid"
119
+
120
+ Lattice.palette = {
121
+ -- Violet-tinted near-black. The cast is slight but it stops the panel
122
+ -- reading as the same grey as every other Studio dock.
123
+ background = Color3.fromRGB(11, 12, 20),
124
+ surface = Color3.fromRGB(18, 20, 31),
125
+ rule = Color3.fromRGB(167, 139, 250),
126
+ text = Color3.fromRGB(206, 212, 226),
127
+ dim = Color3.fromRGB(90, 97, 120),
128
+ green = Color3.fromRGB(110, 231, 168),
129
+ red = Color3.fromRGB(251, 113, 133),
130
+ amber = Color3.fromRGB(251, 191, 36),
131
+ cyan = Color3.fromRGB(103, 232, 249),
132
+ violet = Color3.fromRGB(167, 139, 250),
133
+ }
134
+
135
+ local edges: { Frame } = {}
136
+ local innerEdges: { Frame } = {}
137
+ local dots: { Frame } = {}
138
+
139
+ function Lattice.mount(cell: Frame)
140
+ edges = Draw.pool(cell, MAX_EDGES, "Edge", false)
141
+ innerEdges = Draw.pool(cell, #SHAPES[INNER_SHAPE].edges, "Inner", false)
142
+ -- Built after the edges so the lit corners sit over the strokes they join.
143
+ dots = Draw.pool(cell, MAX_VERTICES, "Vertex", true)
144
+ end
145
+
146
+ function Lattice.unmount()
147
+ edges = {}
148
+ innerEdges = {}
149
+ dots = {}
150
+ end
151
+
152
+ function Lattice.paint(ctx: Theme.Context, _delta: number)
153
+ local shapeIndex = math.clamp(1 + math.floor(ctx.activity * (#SHAPES - 0.001)), 1, #SHAPES)
154
+ local shape = SHAPES[shapeIndex]
155
+ local radius = ctx.size * BASE_RADIUS * ctx.scale
156
+
157
+ -- Wobble the axis rather than the panel: shaking the strip would read as a
158
+ -- glitch, tilting the solid reads as it being knocked.
159
+ local wobble = if ctx.shake > 0
160
+ then Vector3.new(
161
+ math.sin(ctx.clock * 37) * ctx.shake * 0.12,
162
+ math.cos(ctx.clock * 29) * ctx.shake * 0.12,
163
+ 0
164
+ )
165
+ else Vector3.zero
166
+
167
+ local projected: { Vector2 } = {}
168
+ local depths: { number } = {}
169
+ for index, vertex in shape.vertices do
170
+ local point, depth = Draw.project(
171
+ Draw.rotate(vertex, ctx.angle + wobble),
172
+ FOCAL,
173
+ radius,
174
+ ctx.centre
175
+ )
176
+ projected[index] = point
177
+ depths[index] = depth
178
+ end
179
+
180
+ for index = 1, MAX_EDGES do
181
+ local frame = edges[index]
182
+ if frame == nil then
183
+ continue
184
+ end
185
+ local edge = shape.edges[index]
186
+ if edge == nil then
187
+ frame.Visible = false
188
+ continue
189
+ end
190
+ local a, b = edge[1], edge[2]
191
+ local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
192
+ Draw.edge(
193
+ frame,
194
+ projected[a],
195
+ projected[b],
196
+ math.max(1, math.floor(1 + depth * 1.6 + ctx.flare * 0.8)),
197
+ ctx.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35 + ctx.flare * 0.3),
198
+ math.max(0, 0.84 - depth * 0.74 - ctx.flare * 0.3)
199
+ )
200
+ end
201
+
202
+ --[[
203
+ Vertices as points, brighter than the edges they join.
204
+
205
+ Wireframes read as flat when every stroke is the same weight. Lighting
206
+ the corners gives the eye something to track as the solid turns, which is
207
+ what makes it read as rotating rather than merely flickering.
208
+ ]]
209
+ for index = 1, MAX_VERTICES do
210
+ local dot = dots[index]
211
+ if dot == nil then
212
+ continue
213
+ end
214
+ local point = projected[index]
215
+ if point == nil then
216
+ dot.Visible = false
217
+ continue
218
+ end
219
+ local depth = math.clamp((depths[index] + 2) / 4, 0, 1)
220
+ -- The corners carry the flare. A pulse of light travelling through the
221
+ -- vertices is what makes a completed call land as an event rather than
222
+ -- as a bar quietly appearing somewhere off to the right.
223
+ Draw.dot(
224
+ dot,
225
+ point,
226
+ math.max(2, math.floor(1.5 + depth * 2.5 + ctx.flare * 2.5)),
227
+ ctx.tint:Lerp(Color3.new(1, 1, 1), 0.25 + depth * 0.5),
228
+ math.max(0, 0.55 - depth * 0.5 - ctx.flare * 0.45)
229
+ )
230
+ end
231
+
232
+ --[[
233
+ A second solid inside the first, turning the other way.
234
+
235
+ One rotating outline is a loading spinner. Two, at different rates and
236
+ opposite directions, read as a mechanism -- and the counter-rotation
237
+ makes the outer solid's direction obvious, which a lone wireframe hides
238
+ whenever it passes through a symmetrical angle.
239
+ ]]
240
+ local inner = SHAPES[INNER_SHAPE]
241
+ local innerAngle = -ctx.angle * 1.6
242
+ local innerProjected: { Vector2 } = {}
243
+ local innerDepths: { number } = {}
244
+ for index, vertex in inner.vertices do
245
+ local point, depth = Draw.project(
246
+ Draw.rotate(vertex, innerAngle),
247
+ FOCAL,
248
+ radius * INNER_SCALE,
249
+ ctx.centre
250
+ )
251
+ innerProjected[index] = point
252
+ innerDepths[index] = depth
253
+ end
254
+ for index, frame in innerEdges do
255
+ local edge = inner.edges[index]
256
+ if edge == nil then
257
+ frame.Visible = false
258
+ continue
259
+ end
260
+ local a, b = edge[1], edge[2]
261
+ local depth = math.clamp(((innerDepths[a] + innerDepths[b]) / 2 + 2) / 4, 0, 1)
262
+ Draw.edge(
263
+ frame,
264
+ innerProjected[a],
265
+ innerProjected[b],
266
+ 1,
267
+ ctx.tint:Lerp(Color3.new(1, 1, 1), 0.5),
268
+ 0.9 - depth * 0.35
269
+ )
270
+ end
271
+ end
272
+
273
+ --[[
274
+ A still cube, which is the shape people recognise the console by, drawn at a
275
+ three-quarter angle so all three dimensions are visible at once.
276
+ ]]
277
+ function Lattice.swatch(frame: Frame, palette: Theme.Palette)
278
+ local centre = Vector2.new(20, 12)
279
+ local pool = Draw.pool(frame, 12, "Edge", false)
280
+ local shape = SHAPES[3]
281
+ local angle = Vector3.new(0.5, 0.7, 0)
282
+
283
+ local projected: { Vector2 } = {}
284
+ local depths: { number } = {}
285
+ for index, vertex in shape.vertices do
286
+ local point, depth = Draw.project(Draw.rotate(vertex, angle), FOCAL, 5.5, centre)
287
+ projected[index] = point
288
+ depths[index] = depth
289
+ end
290
+ for index, edge in shape.edges do
291
+ local a, b = edge[1], edge[2]
292
+ local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
293
+ Draw.edge(
294
+ pool[index],
295
+ projected[a],
296
+ projected[b],
297
+ 1,
298
+ palette.violet:Lerp(Color3.new(1, 1, 1), depth * 0.4),
299
+ 0.75 - depth * 0.6
300
+ )
301
+ end
302
+ end
303
+
304
+ return Lattice
@@ -0,0 +1,182 @@
1
+ --!strict
2
+ --[[
3
+ Nebula -- the pink one, and the only preset with no straight lines.
4
+
5
+ Soft blobs advected by a slow flow field, magenta through cyan. Every other
6
+ theme in the set draws edges, points or bars; this one draws nothing with a
7
+ corner in it, which is what makes it read as a different instrument rather
8
+ than as the same instrument recoloured.
9
+
10
+ The flow is a pair of sines sampled at each blob's position -- cheap, and
11
+ continuous, so neighbouring blobs drift in similar directions and the field
12
+ holds together instead of scattering. Blobs that leave the cell wrap back to
13
+ the far side rather than being respawned, so the field never thins out.
14
+
15
+ A reply blooms rather than flashes: the blobs swell and brighten over the
16
+ third of a second `flare` lasts. On a strip this small a hard flash reads as
17
+ a rendering fault; a bloom reads as something arriving.
18
+ ]]
19
+
20
+ local Draw = require(script.Parent.Draw)
21
+ local Theme = require(script.Parent.Theme)
22
+
23
+ local BLOBS = 20
24
+
25
+ local Nebula = {}
26
+
27
+ Nebula.id = "nebula"
28
+ Nebula.label = "Nebula"
29
+ Nebula.blurb = "Drifting dust"
30
+
31
+ Nebula.palette = {
32
+ background = Color3.fromRGB(19, 11, 24),
33
+ surface = Color3.fromRGB(30, 18, 38),
34
+ rule = Color3.fromRGB(236, 122, 196),
35
+ text = Color3.fromRGB(238, 214, 236),
36
+ dim = Color3.fromRGB(122, 96, 128),
37
+ green = Color3.fromRGB(142, 230, 196),
38
+ red = Color3.fromRGB(255, 122, 148),
39
+ amber = Color3.fromRGB(250, 190, 140),
40
+ cyan = Color3.fromRGB(134, 226, 240),
41
+ violet = Color3.fromRGB(226, 140, 236),
42
+ }
43
+
44
+ type Blob = {
45
+ frame: Frame,
46
+ position: Vector2,
47
+ -- Fixed per blob: size, how much of the magenta-to-cyan range it sits at,
48
+ -- and its own drift phase.
49
+ scale: number,
50
+ hue: number,
51
+ phase: number,
52
+ }
53
+
54
+ local blobs: { Blob } = {}
55
+ local size = 40
56
+
57
+ --[[
58
+ The flow field. Two sines crossed, which is enough structure to look like a
59
+ current and cheap enough to sample forty times a frame.
60
+
61
+ Time enters slowly. A field that changes as fast as the things moving
62
+ through it reads as noise; at this rate the current itself visibly turns
63
+ over about a minute, under blobs that take a few seconds to cross.
64
+ ]]
65
+ local function flow(at: Vector2, clock: number): Vector2
66
+ local x = math.sin(at.Y * 0.16 + clock * 0.21)
67
+ local y = math.cos(at.X * 0.14 - clock * 0.17)
68
+ return Vector2.new(x, y)
69
+ end
70
+
71
+ function Nebula.mount(cell: Frame)
72
+ size = cell.AbsoluteSize.X
73
+ if size < 8 then
74
+ size = 40
75
+ end
76
+ local frames = Draw.pool(cell, BLOBS, "Blob", true)
77
+ blobs = {}
78
+ for index = 1, BLOBS do
79
+ blobs[index] = {
80
+ frame = frames[index],
81
+ position = Vector2.new(Draw.hash(index, 21) * size, Draw.hash(index, 22) * size),
82
+ scale = 0.4 + Draw.hash(index, 23) * 1.6,
83
+ hue = Draw.hash(index, 24),
84
+ phase = Draw.hash(index, 25) * math.pi * 2,
85
+ }
86
+ end
87
+ end
88
+
89
+ function Nebula.unmount()
90
+ blobs = {}
91
+ end
92
+
93
+ function Nebula.paint(ctx: Theme.Context, delta: number)
94
+ -- Clamped because Studio hands out long frames while it compiles, and an
95
+ -- unclamped step teleports the whole field across the cell in one of them.
96
+ local step = math.min(delta, 1 / 30)
97
+ local drift = 6 + ctx.energy * 22
98
+
99
+ for index = 1, #blobs do
100
+ local blob = blobs[index]
101
+ blob.position += flow(blob.position, ctx.clock + blob.phase) * drift * step
102
+
103
+ -- Wrapped rather than respawned. A blob that reappears at a random point
104
+ -- pops; one that comes back around the far edge is simply still moving.
105
+ local margin = 6
106
+ if blob.position.X < -margin then
107
+ blob.position = Vector2.new(size + margin, blob.position.Y)
108
+ elseif blob.position.X > size + margin then
109
+ blob.position = Vector2.new(-margin, blob.position.Y)
110
+ end
111
+ if blob.position.Y < -margin then
112
+ blob.position = Vector2.new(blob.position.X, size + margin)
113
+ elseif blob.position.Y > size + margin then
114
+ blob.position = Vector2.new(blob.position.X, -margin)
115
+ end
116
+
117
+ --[[
118
+ Breathing on the blob's own phase, so the cloud is never uniform.
119
+ The bloom on top of it is shared, which is what makes a reply read as
120
+ one event happening to the whole field rather than as twenty
121
+ independent flickers.
122
+ ]]
123
+ local breathe = 0.85 + 0.15 * math.sin(ctx.clock * 0.8 + blob.phase)
124
+ local diameter = math.max(
125
+ 2,
126
+ math.floor(blob.scale * 5 * breathe * ctx.scale + ctx.flare * 4)
127
+ )
128
+
129
+ local colour = ctx.palette.violet:Lerp(ctx.palette.cyan, blob.hue)
130
+ Draw.dot(
131
+ blob.frame,
132
+ blob.position,
133
+ diameter,
134
+ colour:Lerp(Color3.new(1, 1, 1), ctx.flare * 0.5),
135
+ --[[
136
+ Deliberately very transparent. Dust is only dust when the blobs
137
+ overlap into something denser than any one of them, and at any
138
+ less than this each blob reads as a discrete circle.
139
+ ]]
140
+ math.clamp(0.82 - ctx.energy * 0.12 - ctx.flare * 0.25, 0, 1)
141
+ )
142
+ end
143
+ end
144
+
145
+ --[[
146
+ History as columns of dust lit from below rather than as bars.
147
+
148
+ Same data, same heights, but drawn with a rounded cap and no hard baseline,
149
+ so the trace belongs to the same picture as the cell.
150
+ ]]
151
+ function Nebula.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
152
+ local frame = slot.frame
153
+ local recency = 1 - slot.age
154
+ local height = math.max(2, math.floor(2 + slot.weight * 20))
155
+
156
+ frame.AnchorPoint = Vector2.new(0.5, 1)
157
+ frame.Size = UDim2.fromOffset(3, height)
158
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
159
+ frame.BackgroundColor3 = if slot.ok
160
+ then ctx.palette.violet:Lerp(ctx.palette.cyan, slot.weight)
161
+ else ctx.palette.red
162
+ frame.BackgroundTransparency = math.clamp(0.72 - recency * 0.6, 0, 1)
163
+ frame.Rotation = 0
164
+ end
165
+
166
+ function Nebula.swatch(frame: Frame, palette: Theme.Palette)
167
+ local pool = Draw.pool(frame, 12, "Blob", true)
168
+ for index = 1, 12 do
169
+ local x = 4 + Draw.hash(index, 31) * 32
170
+ local y = 3 + Draw.hash(index, 32) * 18
171
+ local hue = Draw.hash(index, 33)
172
+ Draw.dot(
173
+ pool[index],
174
+ Vector2.new(x, y),
175
+ 4 + math.floor(Draw.hash(index, 34) * 6),
176
+ palette.violet:Lerp(palette.cyan, hue),
177
+ 0.7
178
+ )
179
+ end
180
+ end
181
+
182
+ return Nebula
@@ -0,0 +1,200 @@
1
+ --!strict
2
+ --[[
3
+ Observatory -- AMOLED, and a session you can read as a sky.
4
+
5
+ The background is true #000000. On an OLED display those pixels are off, so
6
+ the panel has no edges at all and the console appears to float in the Studio
7
+ dock rather than sit in a box. That only works if the black is actually
8
+ black: a near-black at RGB(11, 12, 20) lights every pixel and the illusion
9
+ collapses, which is why this theme does not simply darken the default one.
10
+
11
+ The cell holds a star sphere instead of a prism -- points on a shell, turned
12
+ slowly and projected, twinkling on their own phases. It reads as depth
13
+ without a single straight line, which is the whole point of the contrast
14
+ with Lattice.
15
+
16
+ The trace is the reason the theme exists. Each finished call is a star whose
17
+ height is how slow it was and whose brightness fades as it ages, so forty
18
+ calls of history read as a constellation rather than as a bar chart. A
19
+ failure is a red giant: bigger, redder, and slower to fade, so the one thing
20
+ you actually want to find in a session's history is the one thing the eye
21
+ lands on first.
22
+ ]]
23
+
24
+ local Draw = require(script.Parent.Draw)
25
+ local Theme = require(script.Parent.Theme)
26
+
27
+ -- Points on the shell. Enough to read as a field, few enough that every one of
28
+ -- them is a Frame the paint loop touches every frame.
29
+ local STARS = 26
30
+ local FOCAL = 3.2
31
+ local SHELL = 0.36
32
+
33
+ local Observatory = {}
34
+
35
+ Observatory.id = "observatory"
36
+ Observatory.label = "Observatory"
37
+ Observatory.blurb = "True black. Stars"
38
+
39
+ Observatory.palette = {
40
+ -- Genuinely off, not merely dark. Every other colour here is chosen to hold
41
+ -- contrast against zero rather than against a lifted black.
42
+ background = Color3.fromRGB(0, 0, 0),
43
+ surface = Color3.fromRGB(8, 9, 12),
44
+ rule = Color3.fromRGB(148, 178, 214),
45
+ text = Color3.fromRGB(214, 224, 238),
46
+ dim = Color3.fromRGB(78, 88, 106),
47
+ green = Color3.fromRGB(126, 226, 176),
48
+ red = Color3.fromRGB(248, 122, 122),
49
+ amber = Color3.fromRGB(240, 196, 118),
50
+ cyan = Color3.fromRGB(150, 214, 240),
51
+ violet = Color3.fromRGB(176, 172, 236),
52
+ }
53
+
54
+ local stars: { Frame } = {}
55
+ local meridian: { Frame } = {}
56
+
57
+ --[[
58
+ A fixed point on the shell for star `index`.
59
+
60
+ Derived from the index rather than stored, and derived the same way every
61
+ frame -- a field that re-randomised itself per frame would boil rather than
62
+ drift.
63
+
64
+ Evenly spread rather than randomly placed. A hashed longitude and latitude
65
+ is the obvious way to do this and it produced a visibly lopsided sky: the
66
+ field bunched into one corner of the cell with an empty half beside it,
67
+ which reads as a rendering fault rather than as stars. Twinkle supplies the
68
+ irregularity instead, and it does it without leaving holes.
69
+ ]]
70
+ local function starAt(index: number): Vector3
71
+ return Draw.sphere(index, STARS)
72
+ end
73
+
74
+ function Observatory.mount(cell: Frame)
75
+ -- The meridian sits under the stars so a bright point is never cut by the
76
+ -- line it happens to cross.
77
+ meridian = Draw.pool(cell, 24, "Meridian", true)
78
+ stars = Draw.pool(cell, STARS, "Star", true)
79
+ end
80
+
81
+ function Observatory.unmount()
82
+ stars = {}
83
+ meridian = {}
84
+ end
85
+
86
+ function Observatory.paint(ctx: Theme.Context, _delta: number)
87
+ local radius = ctx.size * SHELL * ctx.scale
88
+ -- A third of Lattice's rate. Stars that turn at a wireframe's pace read as
89
+ -- a spinning object; at this speed they read as a sky the panel is looking
90
+ -- out at, which is the difference the theme is trading on.
91
+ local angle = ctx.angle * 0.34
92
+
93
+ for index = 1, STARS do
94
+ local frame = stars[index]
95
+ if frame == nil then
96
+ continue
97
+ end
98
+ local point, depth = Draw.project(
99
+ Draw.rotate(starAt(index), angle),
100
+ FOCAL,
101
+ radius,
102
+ ctx.centre
103
+ )
104
+ local near = math.clamp((depth + 1) / 2, 0, 1)
105
+
106
+ --[[
107
+ Each star on its own phase and rate, so the field never pulses as one.
108
+
109
+ A shared clock would make the whole sphere breathe together, which
110
+ reads as the panel flickering rather than as stars twinkling. The
111
+ rates are deliberately irrational relative to each other.
112
+ ]]
113
+ local twinkle = 0.5 + 0.5 * math.sin(ctx.clock * (0.7 + Draw.hash(index, 3) * 1.9)
114
+ + Draw.hash(index, 4) * 9)
115
+
116
+ local brightness = near * 0.7 + twinkle * 0.3
117
+ Draw.dot(
118
+ frame,
119
+ point,
120
+ math.max(1, math.floor(1 + near * 2 + ctx.flare * 2)),
121
+ ctx.tint:Lerp(Color3.new(1, 1, 1), 0.3 + brightness * 0.6),
122
+ math.clamp(0.9 - brightness * 0.8 - ctx.flare * 0.4, 0, 1)
123
+ )
124
+ end
125
+
126
+ --[[
127
+ A great circle through the sphere: the horizon an observatory is aimed
128
+ along. Drawn as points rather than segments so it fades in and out with
129
+ depth like the stars do, instead of reading as a hard hoop drawn on top
130
+ of them.
131
+ ]]
132
+ -- 1.05 rather than the 1.18 this started at: at full overshoot the wider
133
+ -- ring reached 21.5px from a centre with 20px of room, so the horizon was
134
+ -- sliced off by the cell's own clipping exactly when the session was busiest.
135
+ for index = 1, #meridian do
136
+ local frame = meridian[index]
137
+ local turn = (index / #meridian) * math.pi * 2
138
+ local point, depth = Draw.project(
139
+ Draw.rotate(Vector3.new(math.cos(turn), 0, math.sin(turn)), angle),
140
+ FOCAL,
141
+ radius * 1.05,
142
+ ctx.centre
143
+ )
144
+ local near = math.clamp((depth + 1) / 2, 0, 1)
145
+ Draw.dot(frame, point, 1, ctx.tint, math.clamp(0.94 - near * 0.35, 0, 1))
146
+ end
147
+ end
148
+
149
+ --[[
150
+ One call as a star rather than as a bar.
151
+
152
+ Height is duration, so a slow call sits high and the eye reads the skyline
153
+ as a latency profile without being told to. Brightness is recency, so the
154
+ newest calls are the ones that stand out and old history recedes on its own
155
+ rather than needing to be scrolled away.
156
+ ]]
157
+ function Observatory.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
158
+ local frame = slot.frame
159
+ local recency = 1 - slot.age
160
+
161
+ -- Failures ignore the height rule. A red giant is large because it failed,
162
+ -- not because it was slow, and conflating the two would hide a fast error
163
+ -- among fast successes at the bottom of the trace.
164
+ local size = if slot.ok
165
+ then math.max(2, math.floor(1.5 + slot.weight * 2.5 + recency * 1.5))
166
+ else math.max(4, math.floor(4 + recency * 2))
167
+
168
+ frame.AnchorPoint = Vector2.new(0.5, 0.5)
169
+ frame.Size = UDim2.fromOffset(size, size)
170
+ -- 0.12 keeps the newest star clear of the trace's top edge, and the 0.82
171
+ -- span leaves the baseline visible under the lowest one.
172
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 0.9 - slot.weight * 0.78, 0)
173
+ frame.BackgroundColor3 = if slot.ok
174
+ then ctx.palette.cyan:Lerp(Color3.new(1, 1, 1), 0.25 + recency * 0.4)
175
+ else ctx.palette.red
176
+ frame.BackgroundTransparency = if slot.ok
177
+ then math.clamp(0.72 - recency * 0.62, 0, 1)
178
+ -- Slower to fade than a success, so the failure in a session's history
179
+ -- is still legible forty calls later.
180
+ else math.clamp(0.45 - recency * 0.4, 0, 1)
181
+ frame.Rotation = 0
182
+ end
183
+
184
+ function Observatory.swatch(frame: Frame, palette: Theme.Palette)
185
+ local pool = Draw.pool(frame, 14, "Star", true)
186
+ for index = 1, 14 do
187
+ local x = 3 + Draw.hash(index, 11) * 34
188
+ local y = 3 + Draw.hash(index, 12) * 18
189
+ local bright = Draw.hash(index, 13)
190
+ Draw.dot(
191
+ pool[index],
192
+ Vector2.new(x, y),
193
+ if bright > 0.85 then 3 else 1 + math.floor(bright * 2),
194
+ palette.cyan:Lerp(Color3.new(1, 1, 1), bright * 0.7),
195
+ 0.85 - bright * 0.7
196
+ )
197
+ end
198
+ end
199
+
200
+ return Observatory