@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.5

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 (37) 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/instances.js +49 -2
  12. package/dist/tools/instances.js.map +1 -1
  13. package/dist/tools/perf.js +9 -8
  14. package/dist/tools/perf.js.map +1 -1
  15. package/dist/tools/scripts.js +10 -1
  16. package/dist/tools/scripts.js.map +1 -1
  17. package/package.json +1 -1
  18. package/plugin/src/Config.luau +1 -1
  19. package/plugin/src/Console.luau +257 -116
  20. package/plugin/src/Serialize.luau +43 -3
  21. package/plugin/src/ThemePicker.luau +458 -0
  22. package/plugin/src/Themes/Aurora.luau +147 -0
  23. package/plugin/src/Themes/Blueprint.luau +213 -0
  24. package/plugin/src/Themes/Draw.luau +177 -0
  25. package/plugin/src/Themes/Lattice.luau +304 -0
  26. package/plugin/src/Themes/Nebula.luau +182 -0
  27. package/plugin/src/Themes/Observatory.luau +200 -0
  28. package/plugin/src/Themes/Orbit.luau +268 -0
  29. package/plugin/src/Themes/Phosphor.luau +200 -0
  30. package/plugin/src/Themes/Theme.luau +121 -0
  31. package/plugin/src/Themes/Void.luau +230 -0
  32. package/plugin/src/Themes/init.luau +116 -0
  33. package/plugin/src/Visuals.luau +788 -907
  34. package/plugin/src/handlers/Discover.luau +75 -1
  35. package/plugin/src/handlers/Exec.luau +17 -5
  36. package/plugin/src/handlers/Instances.luau +35 -3
  37. package/plugin/src/init.server.luau +386 -354
@@ -0,0 +1,200 @@
1
+ --!strict
2
+ --[[
3
+ Phosphor -- a CRT oscilloscope.
4
+
5
+ A Lissajous figure traced by a moving beam, with the phosphor decaying
6
+ behind it. The head is white-hot, the tail fades to green over about forty
7
+ samples, and the whole cell sits under scanlines.
8
+
9
+ It deliberately does NOT reuse the wireframe. Drawing Lattice's solid in
10
+ green with a trail would be the obvious CRT gag and would leave two of the
11
+ eight presets doing the same thing in different colours. A beam tracing a
12
+ curve is what a scope actually does, and it gives the theme its own answer
13
+ to "is this alive": the figure's shape changes with the session, because the
14
+ frequency ratio is driven by activity. Idle it draws a lazy ellipse; busy it
15
+ knots into a rose.
16
+
17
+ The sample buffer is a ring rather than a growing list. The head advances,
18
+ the sample under it is overwritten, and age is computed from the distance
19
+ back to the head -- so nothing is allocated or shifted per frame.
20
+ ]]
21
+
22
+ local Draw = require(script.Parent.Draw)
23
+ local Theme = require(script.Parent.Theme)
24
+
25
+ local SAMPLES = 46
26
+ -- Samples per second the beam advances. Fast enough that the figure is a curve
27
+ -- rather than a dotted line, slow enough that the decay is visible.
28
+ local RATE = 60
29
+
30
+ local Phosphor = {}
31
+
32
+ Phosphor.id = "phosphor"
33
+ Phosphor.label = "Phosphor"
34
+ Phosphor.blurb = "CRT scope beam"
35
+
36
+ Phosphor.palette = {
37
+ background = Color3.fromRGB(4, 8, 5),
38
+ surface = Color3.fromRGB(9, 16, 11),
39
+ rule = Color3.fromRGB(86, 232, 122),
40
+ text = Color3.fromRGB(178, 236, 190),
41
+ dim = Color3.fromRGB(70, 112, 82),
42
+ green = Color3.fromRGB(86, 240, 128),
43
+ red = Color3.fromRGB(248, 118, 96),
44
+ amber = Color3.fromRGB(240, 208, 106),
45
+ cyan = Color3.fromRGB(122, 234, 208),
46
+ violet = Color3.fromRGB(150, 226, 150),
47
+ }
48
+
49
+ local dots: { Frame } = {}
50
+ local scanlines: { Frame } = {}
51
+ local samples: { Vector2 } = {}
52
+ local head = 0
53
+ local accumulator = 0
54
+
55
+ function Phosphor.mount(cell: Frame)
56
+ dots = Draw.pool(cell, SAMPLES, "Sample", true)
57
+
58
+ scanlines = {}
59
+ samples = {}
60
+ head = 0
61
+ accumulator = 0
62
+
63
+ --[[
64
+ Scanlines last, so they lie OVER the beam.
65
+
66
+ Underneath they were invisible -- the beam is the brightest thing in the
67
+ cell and simply covered them. A CRT's lines cross what it is drawing;
68
+ that is what makes it read as a screen rather than as a dark background
69
+ with stripes on it.
70
+ ]]
71
+ for index = 1, 10 do
72
+ local line = Instance.new("Frame")
73
+ line.Name = string.format("Scan%02d", index)
74
+ line.BorderSizePixel = 0
75
+ line.BackgroundColor3 = Color3.new(0, 0, 0)
76
+ line.BackgroundTransparency = 0.78
77
+ line.Size = UDim2.new(1, 0, 0, 1)
78
+ line.Position = UDim2.fromOffset(0, index * 4)
79
+ line.ZIndex = 4
80
+ line.Parent = cell
81
+ scanlines[index] = line
82
+ end
83
+ end
84
+
85
+ function Phosphor.unmount()
86
+ dots = {}
87
+ scanlines = {}
88
+ samples = {}
89
+ end
90
+
91
+ function Phosphor.paint(ctx: Theme.Context, delta: number)
92
+ local radius = ctx.size * 0.33 * ctx.scale
93
+
94
+ --[[
95
+ The figure's ratio, from activity.
96
+
97
+ A Lissajous curve closes when its two frequencies are a simple ratio, so
98
+ holding X at 2 and sliding Y between 3 and roughly 5 walks it from an
99
+ open ellipse through a figure-eight into a knot. The session's load
100
+ therefore changes the SHAPE, not just the speed -- which is the same
101
+ trick Lattice plays with its solids, done continuously.
102
+ ]]
103
+ local ratio = 3 + math.clamp(ctx.activity, 0, 1) * 2.1
104
+
105
+ -- Fixed-step sampling. Advancing one sample per frame would make the trail's
106
+ -- length depend on Studio's frame rate, so the beam would visibly speed up
107
+ -- whenever the editor was idle.
108
+ accumulator += math.min(delta, 1 / 15)
109
+ local advance = math.floor(accumulator * RATE)
110
+ if advance > 0 then
111
+ accumulator -= advance / RATE
112
+ for _ = 1, math.min(advance, SAMPLES) do
113
+ head += 1
114
+ local t = head / RATE
115
+ samples[(head % SAMPLES) + 1] = Vector2.new(
116
+ math.sin(t * 2) * radius,
117
+ math.sin(t * ratio + ctx.clock * 0.15) * radius
118
+ )
119
+ end
120
+ end
121
+
122
+ for index = 1, SAMPLES do
123
+ local frame = dots[index]
124
+ local point = samples[index]
125
+ if frame == nil then
126
+ continue
127
+ end
128
+ if point == nil then
129
+ frame.Visible = false
130
+ continue
131
+ end
132
+
133
+ --[[
134
+ How far back from the head this sample is, 0 (newest) to 1 (about to
135
+ be overwritten). The modulo is what lets the ring buffer stand in for
136
+ an ordered trail without moving anything.
137
+ ]]
138
+ local back = (head - (index - 1)) % SAMPLES
139
+ local age = back / SAMPLES
140
+
141
+ -- Squared, so the head is a bright point with a long dim tail rather
142
+ -- than an evenly grey worm. Phosphor decays fast at first.
143
+ local glow = (1 - age) * (1 - age)
144
+
145
+ Draw.dot(
146
+ frame,
147
+ ctx.centre + point,
148
+ math.max(1, math.floor(1 + glow * 2 + ctx.flare * 1.5)),
149
+ ctx.palette.green:Lerp(Color3.new(1, 1, 1), glow * 0.85),
150
+ math.clamp(0.94 - glow * 0.9 - ctx.flare * 0.2, 0, 1)
151
+ )
152
+ end
153
+ end
154
+
155
+ --[[
156
+ History as scope columns: hard-edged, one pixel wide, no rounding. The trace
157
+ is the one part of a scope's screen that is supposed to look like a chart.
158
+ ]]
159
+ function Phosphor.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
160
+ local frame = slot.frame
161
+ local recency = 1 - slot.age
162
+ local height = math.max(1, math.floor(1 + slot.weight * 22))
163
+
164
+ frame.AnchorPoint = Vector2.new(0.5, 1)
165
+ frame.Size = UDim2.fromOffset(2, height)
166
+ frame.Position = UDim2.new(frame.Position.X.Scale, frame.Position.X.Offset, 1, 0)
167
+ frame.BackgroundColor3 = if slot.ok
168
+ then ctx.palette.green:Lerp(Color3.new(1, 1, 1), recency * 0.4)
169
+ else ctx.palette.red
170
+ frame.BackgroundTransparency = math.clamp(0.7 - recency * 0.65, 0, 1)
171
+ frame.Rotation = 0
172
+ end
173
+
174
+ function Phosphor.swatch(frame: Frame, palette: Theme.Palette)
175
+ local centre = Vector2.new(20, 12)
176
+ local pool = Draw.pool(frame, 30, "Sample", true)
177
+ for index = 1, 30 do
178
+ local t = index / 6
179
+ local glow = index / 30
180
+ Draw.dot(
181
+ pool[index],
182
+ centre + Vector2.new(math.sin(t * 2) * 15, math.sin(t * 3) * 9),
183
+ 1 + math.floor(glow * 2),
184
+ palette.green:Lerp(Color3.new(1, 1, 1), glow * 0.8),
185
+ 0.85 - glow * 0.8
186
+ )
187
+ end
188
+ for index = 1, 6 do
189
+ local line = Instance.new("Frame")
190
+ line.BorderSizePixel = 0
191
+ line.BackgroundColor3 = Color3.new(0, 0, 0)
192
+ line.BackgroundTransparency = 0.75
193
+ line.Size = UDim2.new(1, 0, 0, 1)
194
+ line.Position = UDim2.fromOffset(0, index * 4)
195
+ line.ZIndex = 4
196
+ line.Parent = frame
197
+ end
198
+ end
199
+
200
+ return Phosphor
@@ -0,0 +1,121 @@
1
+ --!strict
2
+ --[[
3
+ The contract every preset satisfies.
4
+
5
+ A theme owns two surfaces and nothing else. `cell` is the square at the left
6
+ of the activity band -- the slot the wireframe prism occupies under the
7
+ default theme, and which any other theme is free to fill with something that
8
+ is not a prism at all. `slots` are the forty most recent calls, drawn as bars
9
+ under the default theme and as anything else under the rest.
10
+
11
+ What a theme deliberately does NOT own is the simulation. Energy, activity,
12
+ flare, shake, the scale spring and the quiet timer are computed once in
13
+ Visuals and handed to whichever preset is active. That is the whole reason
14
+ eight presets do not mean eight animation systems: they are eight painters
15
+ over one set of physics, so a preset is a few hundred lines of drawing and
16
+ holds no state that could drift out of step with the session.
17
+ ]]
18
+
19
+ export type Palette = {
20
+ background: Color3,
21
+ surface: Color3,
22
+ rule: Color3,
23
+ text: Color3,
24
+ dim: Color3,
25
+ green: Color3,
26
+ red: Color3,
27
+ amber: Color3,
28
+ cyan: Color3,
29
+ violet: Color3,
30
+ }
31
+
32
+ --[[
33
+ The session, as numbers a painter can use.
34
+
35
+ Everything here decays on its own, so a painter never has to schedule
36
+ anything: read the value, draw that frame, and the motion follows.
37
+
38
+ `energy` rises with each command and falls over about a second -- use it for
39
+ anything that should react. `activity` is the slower average, over roughly
40
+ ten seconds, and is what should drive how BUSY a scene looks rather than how
41
+ startled. `flare` spikes to 1 the instant a reply lands and is gone in a
42
+ third of a second, which is what makes a completed call read as an event.
43
+ `shake` is the same but only for failures. `quiet` is seconds since anything
44
+ ran; past `QUIET_AFTER` the session has stopped rather than paused, and a
45
+ scene that keeps working at full pace after that is claiming an activity
46
+ which is not happening.
47
+ ]]
48
+ export type Context = {
49
+ palette: Palette,
50
+ -- Connection state blended toward the kind of work last done.
51
+ tint: Color3,
52
+ energy: number,
53
+ activity: number,
54
+ flare: number,
55
+ shake: number,
56
+ -- The scale spring: 1 at rest, overshoots past 1 on a reply, 0.82 when idle.
57
+ scale: number,
58
+ quiet: number,
59
+ -- Accumulated rotation on three axes. Meaningful to anything that turns;
60
+ -- ignorable by anything that does not.
61
+ angle: Vector3,
62
+ -- `os.clock()`, sampled once per frame so every painter agrees on the time.
63
+ clock: number,
64
+ -- The cell is square; this is its side in pixels.
65
+ size: number,
66
+ centre: Vector2,
67
+ }
68
+
69
+ --[[
70
+ One finished call, as history rather than as an event.
71
+
72
+ `age` is 0 for the newest and rises toward 1 for the oldest still on screen,
73
+ which is what lets a preset fade its history out without tracking when
74
+ anything happened. `frame` is pooled and reused as bars shift left, so a
75
+ painter must set every property it cares about rather than assuming the
76
+ frame arrives clean.
77
+ ]]
78
+ export type Slot = {
79
+ frame: Frame,
80
+ milliseconds: number,
81
+ ok: boolean,
82
+ title: string,
83
+ age: number,
84
+ -- Duration against the "slow" reference, clamped to 1. Height, for a bar.
85
+ weight: number,
86
+ }
87
+
88
+ export type Theme = {
89
+ id: string,
90
+ label: string,
91
+ -- One line, shown under the label in the picker.
92
+ blurb: string,
93
+ palette: Palette,
94
+ --[[
95
+ Builds whatever the cell needs, once. Pooled Instances belong here:
96
+ allocating inside `paint` is how a decorative strip becomes a performance
97
+ complaint.
98
+ ]]
99
+ mount: (cell: Frame) -> (),
100
+ paint: (ctx: Context, delta: number) -> (),
101
+ --[[
102
+ Destroys what `mount` built. Called on every theme switch, so it must
103
+ leave the cell empty enough for the next preset to start clean.
104
+ ]]
105
+ unmount: () -> (),
106
+ --[[
107
+ Draws one call of history. Optional: a preset that wants ordinary bars
108
+ omits it and Visuals draws them.
109
+ ]]
110
+ paintSlot: ((slot: Slot, ctx: Context) -> ())?,
111
+ --[[
112
+ A still miniature for the picker, drawn into a 40x24 frame.
113
+
114
+ Static on purpose. Eight live renderers running behind a hover menu is
115
+ the one place the "only one preset runs at a time" argument breaks, and
116
+ it breaks in a drawer the user looks at for two seconds.
117
+ ]]
118
+ swatch: (frame: Frame, palette: Palette) -> (),
119
+ }
120
+
121
+ return {}
@@ -0,0 +1,230 @@
1
+ --!strict
2
+ --[[
3
+ Void -- an accretion disc, and the most literal of the eight.
4
+
5
+ A dark core with matter spiralling into it. Orbital speed rises as radius
6
+ falls, the way it actually does, so the inner ring visibly outruns the outer
7
+ one and the disc shears instead of turning as a plate. That single detail is
8
+ what separates it from a spinning ring of dots.
9
+
10
+ The trace is where the metaphor pays for itself. A slow call is drawn
11
+ stretched and shifted red, so latency is not a height you have to compare
12
+ against its neighbours -- it is a thing visibly being pulled apart. A
13
+ forty-bar chart makes you measure; this makes you look.
14
+
15
+ It is also the least legible preset in the set, and that is a real trade
16
+ rather than an oversight: the disc is dark, the motion is continuous, and
17
+ there is no baseline to read heights against. It is here because someone
18
+ will want it, not because it is the best instrument.
19
+ ]]
20
+
21
+ local Draw = require(script.Parent.Draw)
22
+ local Theme = require(script.Parent.Theme)
23
+
24
+ local MOTES = 34
25
+ local RINGS = 4
26
+
27
+ --[[
28
+ How far the disc reaches, and how flat it is seen.
29
+
30
+ The reach was already near the cell's limit; the disc read as small because
31
+ it was a HAIRLINE -- flattened to 0.3, four rings of it occupied about 10px
32
+ of vertical space in a 40px cell, so a wide thin smear was doing the work of
33
+ a picture. Opening the viewing angle to 0.48 nearly doubles the height for
34
+ no extra width, which is the axis that actually had room spare.
35
+ ]]
36
+ local DISC_SPREAD = 0.54
37
+ local FLATTEN = 0.48
38
+
39
+ -- The disc barely breathes: same reasoning as Orbit. A ring sized to fill the
40
+ -- cell at rest would be cut off by the clip at every overshoot, so the CORE
41
+ -- carries the spring and the matter around it holds its radius.
42
+ local DISC_BREATH = 0.1
43
+
44
+ local Void = {}
45
+
46
+ Void.id = "void"
47
+ Void.label = "Void"
48
+ Void.blurb = "Accretion disc"
49
+
50
+ Void.palette = {
51
+ background = Color3.fromRGB(6, 5, 10),
52
+ surface = Color3.fromRGB(13, 11, 20),
53
+ rule = Color3.fromRGB(168, 108, 236),
54
+ text = Color3.fromRGB(206, 200, 222),
55
+ dim = Color3.fromRGB(84, 78, 102),
56
+ green = Color3.fromRGB(120, 220, 176),
57
+ red = Color3.fromRGB(252, 96, 118),
58
+ amber = Color3.fromRGB(250, 174, 108),
59
+ cyan = Color3.fromRGB(126, 202, 244),
60
+ violet = Color3.fromRGB(178, 122, 246),
61
+ }
62
+
63
+ local motes: { Frame } = {}
64
+ local core: Frame? = nil
65
+ local shadow: Frame? = nil
66
+
67
+ --[[
68
+ Which ring a mote belongs to, and where it sits on that ring.
69
+
70
+ Distributed by index rather than randomly so every ring gets a similar share
71
+ -- a random assignment leaves visible gaps at forty motes, and a gap in a
72
+ disc reads as a rendering fault rather than as structure.
73
+ ]]
74
+ local function ringOf(index: number): (number, number)
75
+ local ring = 1 + (index - 1) % RINGS
76
+ local along = Draw.hash(index, 41)
77
+ return ring, along
78
+ end
79
+
80
+ function Void.mount(cell: Frame)
81
+ local dark = Instance.new("Frame")
82
+ dark.Name = "Shadow"
83
+ dark.AnchorPoint = Vector2.new(0.5, 0.5)
84
+ dark.BorderSizePixel = 0
85
+ dark.Parent = cell
86
+ local darkCorner = Instance.new("UICorner")
87
+ darkCorner.CornerRadius = UDim.new(1, 0)
88
+ darkCorner.Parent = dark
89
+ shadow = dark
90
+
91
+ motes = Draw.pool(cell, MOTES, "Mote", true)
92
+
93
+ --[[
94
+ The core is built LAST so it draws over the motes.
95
+
96
+ That is the entire illusion: matter passing behind the core has to
97
+ disappear behind something, and with the core underneath, motes crossed
98
+ it and the disc read as a flat ring with a dot in the middle.
99
+ ]]
100
+ local sphere = Instance.new("Frame")
101
+ sphere.Name = "Core"
102
+ sphere.AnchorPoint = Vector2.new(0.5, 0.5)
103
+ sphere.BorderSizePixel = 0
104
+ sphere.Parent = cell
105
+ local corner = Instance.new("UICorner")
106
+ corner.CornerRadius = UDim.new(1, 0)
107
+ corner.Parent = sphere
108
+ core = sphere
109
+ end
110
+
111
+ function Void.unmount()
112
+ motes = {}
113
+ core = nil
114
+ shadow = nil
115
+ end
116
+
117
+ function Void.paint(ctx: Theme.Context, _delta: number)
118
+ local breath = (1 - DISC_BREATH) + ctx.scale * DISC_BREATH
119
+ local scale = ctx.size * DISC_SPREAD * breath
120
+ -- Lensing: a failure warps the disc rather than shaking the panel, which on
121
+ -- a strip this small would read as a glitch.
122
+ local warp = 1 + ctx.shake * 0.28
123
+
124
+ for index = 1, MOTES do
125
+ local frame = motes[index]
126
+ if frame == nil then
127
+ continue
128
+ end
129
+ local ring, along = ringOf(index)
130
+ local radius = (0.3 + ring * 0.14) * scale * warp
131
+
132
+ --[[
133
+ Keplerian, near enough: inner orbits are faster. Anything uniform
134
+ turns the disc as a rigid plate, and the shear is what says the
135
+ thing at the centre is pulling rather than spinning.
136
+ ]]
137
+ local speed = 2.4 / (0.3 + ring * 0.14)
138
+ local turn = along * math.pi * 2 + ctx.clock * speed * 0.1 * (1 + ctx.energy)
139
+
140
+ -- Flattened on Y: a disc seen nearly edge-on, not a set of circles.
141
+ local x = math.cos(turn) * radius
142
+ local y = math.sin(turn) * radius * FLATTEN
143
+ -- Depth from the same angle, so a mote on the far side dims.
144
+ local near = (math.sin(turn) + 1) / 2
145
+
146
+ local heat = 1 - ring / RINGS
147
+ Draw.dot(
148
+ frame,
149
+ ctx.centre + Vector2.new(x, y),
150
+ math.max(2, math.floor(1.5 + heat * 2.5 + ctx.flare * 1.5)),
151
+ ctx.palette.violet
152
+ :Lerp(ctx.palette.amber, heat)
153
+ :Lerp(Color3.new(1, 1, 1), ctx.flare * 0.4),
154
+ math.clamp(0.82 - near * 0.45 - heat * 0.2 - ctx.flare * 0.25, 0, 1)
155
+ )
156
+ end
157
+
158
+ local halo = shadow
159
+ if halo ~= nil then
160
+ Draw.dot(
161
+ halo,
162
+ ctx.centre,
163
+ math.max(12, math.floor(16 * ctx.scale + ctx.energy * 5)),
164
+ ctx.palette.violet,
165
+ 0.88
166
+ )
167
+ end
168
+
169
+ local sphere = core
170
+ if sphere ~= nil then
171
+ -- The core does not brighten on a reply. Everything around it does, and
172
+ -- the hole staying black through a flare is what sells it as a hole.
173
+ Draw.dot(
174
+ sphere,
175
+ ctx.centre,
176
+ math.max(6, math.floor(9 * ctx.scale)),
177
+ ctx.palette.background,
178
+ 0
179
+ )
180
+ end
181
+ end
182
+
183
+ --[[
184
+ History falling inward.
185
+
186
+ A slow call is drawn taller AND redder AND further from the baseline, so
187
+ three channels carry the same fact. That is redundant on purpose: the disc
188
+ behind it is busy, and one channel would be lost in it.
189
+ ]]
190
+ function Void.paintSlot(slot: Theme.Slot, ctx: Theme.Context)
191
+ local frame = slot.frame
192
+ local recency = 1 - slot.age
193
+ local height = math.max(2, math.floor(2 + slot.weight * 18))
194
+
195
+ frame.AnchorPoint = Vector2.new(0.5, 1)
196
+ frame.Size = UDim2.fromOffset(2, height)
197
+ -- Lifted off the baseline in proportion to how slow it was: the bar has
198
+ -- started to fall in.
199
+ frame.Position = UDim2.new(
200
+ frame.Position.X.Scale,
201
+ frame.Position.X.Offset,
202
+ 1,
203
+ -math.floor(slot.weight * 4)
204
+ )
205
+ frame.BackgroundColor3 = if slot.ok
206
+ then ctx.palette.amber:Lerp(ctx.palette.red, slot.weight)
207
+ else ctx.palette.red
208
+ frame.BackgroundTransparency = math.clamp(0.7 - recency * 0.6, 0, 1)
209
+ frame.Rotation = 0
210
+ end
211
+
212
+ function Void.swatch(frame: Frame, palette: Theme.Palette)
213
+ local centre = Vector2.new(20, 12)
214
+ local pool = Draw.pool(frame, 26, "Mote", true)
215
+ for index = 1, 24 do
216
+ local ring = 1 + (index - 1) % 3
217
+ local turn = Draw.hash(index, 43) * math.pi * 2
218
+ local radius = 5 + ring * 3.4
219
+ Draw.dot(
220
+ pool[index],
221
+ centre + Vector2.new(math.cos(turn) * radius, math.sin(turn) * radius * FLATTEN),
222
+ 1 + math.floor((3 - ring) * 0.7),
223
+ palette.violet:Lerp(palette.amber, 1 - ring / 3),
224
+ 0.45
225
+ )
226
+ end
227
+ Draw.dot(pool[25], centre, 9, palette.background, 0)
228
+ end
229
+
230
+ return Void
@@ -0,0 +1,116 @@
1
+ --!strict
2
+ --[[
3
+ The preset registry: which themes exist, which is active, and who to tell
4
+ when that changes.
5
+
6
+ Deliberately dumb. It holds no drawing code and no Instances -- it maps ids
7
+ to modules, remembers one id, and fires listeners. Everything visual lives
8
+ in the preset modules and everything stateful lives in Visuals, so this file
9
+ is the one place you can look to answer "what themes are there" without
10
+ reading eight renderers.
11
+
12
+ Persistence is not done here either. `plugin` is a script-level global that
13
+ ModuleScripts do not get, so init.server.luau reads the stored id at startup
14
+ and writes it back from `onChanged`. That keeps the only Studio-specific
15
+ dependency in the one file that already has it.
16
+ ]]
17
+
18
+ local Theme = require(script.Theme)
19
+
20
+ local Themes = {}
21
+
22
+ export type Theme = Theme.Theme
23
+ export type Palette = Theme.Palette
24
+ export type Context = Theme.Context
25
+ export type Slot = Theme.Slot
26
+
27
+ --[[
28
+ Presentation order in the picker, which is not alphabetical.
29
+
30
+ Lattice first because it is the default and the one people return to. Then
31
+ the two dark presets closest to it, then the colourful ones, then the two
32
+ that abandon the aesthetic entirely. A user scanning the drawer should be
33
+ able to stop as soon as the list stops looking like what they came for.
34
+ ]]
35
+ local ORDER: { Theme.Theme } = {
36
+ require(script.Lattice),
37
+ require(script.Observatory),
38
+ require(script.Orbit),
39
+ require(script.Void),
40
+ require(script.Nebula),
41
+ require(script.Aurora),
42
+ require(script.Phosphor),
43
+ require(script.Blueprint),
44
+ }
45
+
46
+ local byId: { [string]: Theme.Theme } = {}
47
+ for _, theme in ORDER do
48
+ byId[theme.id] = theme
49
+ end
50
+
51
+ local DEFAULT = "lattice"
52
+ local activeId = DEFAULT
53
+ local listeners: { (Theme.Theme) -> () } = {}
54
+
55
+ function Themes.list(): { Theme.Theme }
56
+ return ORDER
57
+ end
58
+
59
+ --[[
60
+ Looks up a preset, falling back to the default rather than erroring.
61
+
62
+ The id can come from a saved setting written by an older or newer build, so
63
+ an unknown one is expected rather than exceptional -- and a console that
64
+ refuses to start because a theme was renamed would be a bad trade for a
65
+ cosmetic feature.
66
+ ]]
67
+ function Themes.get(id: string?): Theme.Theme
68
+ if id ~= nil then
69
+ local found = byId[id]
70
+ if found ~= nil then
71
+ return found
72
+ end
73
+ end
74
+ return byId[DEFAULT]
75
+ end
76
+
77
+ function Themes.active(): Theme.Theme
78
+ return Themes.get(activeId)
79
+ end
80
+
81
+ function Themes.palette(): Theme.Palette
82
+ return Themes.active().palette
83
+ end
84
+
85
+ function Themes.activeId(): string
86
+ return activeId
87
+ end
88
+
89
+ --[[
90
+ Switches presets and tells everyone who cares.
91
+
92
+ Returns whether anything changed, so a caller re-selecting the current theme
93
+ does not pay for a full remount and a repaint of the log.
94
+ ]]
95
+ function Themes.use(id: string): boolean
96
+ local theme = Themes.get(id)
97
+ if theme.id == activeId then
98
+ return false
99
+ end
100
+ activeId = theme.id
101
+ for _, listener in listeners do
102
+ listener(theme)
103
+ end
104
+ return true
105
+ end
106
+
107
+ --[[
108
+ Registers a listener. There is no unsubscribe: every listener here lives as
109
+ long as the plugin does, and an unsubscribe nobody calls is a function that
110
+ rots rather than one that helps.
111
+ ]]
112
+ function Themes.onChanged(listener: (Theme.Theme) -> ())
113
+ table.insert(listeners, listener)
114
+ end
115
+
116
+ return Themes