labforge 0.1.0__py3-none-any.whl

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.
labforge/scan.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ The scan engine: run the worker once, or over a cartesian grid of scan axes.
3
+
4
+ Pure Python, no Flet — unit-testable without a UI.
5
+ """
6
+
7
+ import itertools
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class ScanResult:
13
+ """
14
+ The collected output of a parameter scan.
15
+
16
+ A list of (params, result) records rather than a tuple-keyed dict: records
17
+ are self-describing (each carries its full kwarg dict), preserve cartesian
18
+ order, and avoid float-equality lookups.
19
+
20
+ Parameters
21
+ ----------
22
+ keys: list
23
+ The scanned kwarg names, in control order.
24
+ points: list
25
+ (params dict, worker return) tuples, in cartesian-product order.
26
+ """
27
+
28
+ keys: list
29
+ points: list
30
+
31
+ def __iter__(self):
32
+ return iter(self.points)
33
+
34
+ def __len__(self):
35
+ return len(self.points)
36
+
37
+ def values(self):
38
+ """The worker returns alone, in cartesian-product order."""
39
+ return [value for _, value in self.points]
40
+
41
+ def axis(self, key):
42
+ """The distinct values one scanned kwarg took, in entry order."""
43
+ return list(dict.fromkeys(params[key] for params, _ in self.points))
44
+
45
+
46
+ def run_worker(func, values):
47
+ """
48
+ Call the worker once, or over the cartesian grid of its scan axes.
49
+
50
+ The worker always receives scalars: an axis is expanded into one call per
51
+ grid point, never passed through as a list. Viz and analysis functions tell
52
+ the two cases apart with isinstance(data, ScanResult).
53
+
54
+ Parameters
55
+ ----------
56
+ func: callable
57
+ The registered worker.
58
+ values: dict
59
+ Parsed control values; list-valued entries are scan axes.
60
+
61
+ Returns
62
+ -------
63
+ The worker's bare return for a pure-scalar call, or a ScanResult when any
64
+ axis is a list.
65
+ """
66
+ axes = {key: value for key, value in values.items() if isinstance(value, list)}
67
+ if not axes:
68
+ return func(**values)
69
+
70
+ fixed = {key: value for key, value in values.items() if key not in axes}
71
+ points = []
72
+ for combo in itertools.product(*axes.values()):
73
+ params = dict(fixed, **dict(zip(axes.keys(), combo)))
74
+ points.append((params, func(**params)))
75
+ return ScanResult(keys=list(axes.keys()), points=points)
labforge/shell.py ADDED
@@ -0,0 +1,220 @@
1
+ """
2
+ The app chrome: top bar, navigation rail or scroll layout, and the single
3
+ LabState every page shares.
4
+ """
5
+
6
+ import flet as ft
7
+
8
+ from . import theme as themes
9
+ from . import ui
10
+ from .pages import analysis, scroll, simulation, theory, visualization
11
+ from .state import LabState
12
+
13
+ PAGES = [
14
+ ("Theory", ft.Icons.MENU_BOOK, theory.build),
15
+ ("Simulation", ft.Icons.TERMINAL, simulation.build),
16
+ ("Visualization", ft.Icons.AUTO_GRAPH, visualization.build),
17
+ ("Analysis", ft.Icons.TROUBLESHOOT, analysis.build),
18
+ ]
19
+
20
+ LAYOUTS = ("pages", "scroll")
21
+
22
+
23
+ def color_scheme(theme):
24
+ """
25
+ Map a Theme onto the Flet ColorScheme slots the pages resolve through.
26
+
27
+ Pages never name a hex; they style themselves with ft.Colors tokens, so this
28
+ mapping is the only place the palette reaches the UI.
29
+ """
30
+ return ft.ColorScheme(
31
+ primary=theme.accent,
32
+ on_primary=theme.on_accent,
33
+ primary_container=theme.accent_dim,
34
+ on_primary_container=theme.on_accent_dim,
35
+ secondary=theme.accent,
36
+ on_secondary=theme.on_accent,
37
+ secondary_container=theme.accent_dim,
38
+ on_secondary_container=theme.on_accent_dim,
39
+ surface=theme.surface,
40
+ on_surface=theme.on_surface,
41
+ on_surface_variant=theme.on_surface_variant,
42
+ surface_container_lowest=theme.surface_lowest,
43
+ surface_container_low=theme.surface_low,
44
+ surface_container=theme.surface_container,
45
+ surface_container_high=theme.surface_high,
46
+ surface_container_highest=theme.surface_highest,
47
+ outline=theme.outline,
48
+ outline_variant=theme.outline_variant,
49
+ surface_tint=theme.accent,
50
+ )
51
+
52
+
53
+ def build_main(lab, layout="pages", theme=themes.DEFAULT):
54
+ """
55
+ Return the Flet entry point — a main(page) callable — for a Lab.
56
+
57
+ Parameters
58
+ ----------
59
+ lab: Lab
60
+ The validated registrations and branding.
61
+ layout: str
62
+ "pages" for the rail-navigated four-page shell, "scroll" for one
63
+ continuous scrolling page.
64
+ theme: str
65
+ A key of theme.THEMES. Selected here, once, before the app serves —
66
+ figures and equations read it back through theme.active().
67
+
68
+ Returns
69
+ -------
70
+ A main(page) callable; also the headless seam the page-tree tests drive.
71
+ """
72
+ if layout not in LAYOUTS:
73
+ raise ValueError(f"layout must be one of {sorted(LAYOUTS)}, got {layout!r}.")
74
+
75
+ palette = themes.use(theme)
76
+ scheme = color_scheme(palette)
77
+
78
+ # Flet calls main(page) once per connection, so every browser tab that opens
79
+ # the served app gets its own LabState. Nothing may be cached out here
80
+ # except immutable config.
81
+ def main(page):
82
+ page.title = lab.page_title
83
+ # The mode matches the palette so widget defaults the scheme does not
84
+ # name (shadows, ripples, error tones) derive with the right brightness.
85
+ page.theme_mode = ft.ThemeMode.DARK if palette.mode == "dark" else ft.ThemeMode.LIGHT
86
+ # The seed fills the scheme slots color_scheme leaves unset; the same
87
+ # Theme is set on both slots so the explicit surfaces win regardless of
88
+ # which one Flet consults for the mode.
89
+ page_theme = ft.Theme(
90
+ color_scheme_seed=palette.accent, color_scheme=scheme, font_family=ui.FONT_BODY
91
+ )
92
+ page.theme = page_theme
93
+ page.dark_theme = page_theme
94
+ page.window.width = 1120
95
+ page.window.height = 840
96
+ page.window.min_width = 900
97
+ page.window.min_height = 640
98
+
99
+ # The one state object every page reads from and writes to.
100
+ state = LabState(lab)
101
+
102
+ # App mark: the lab's icon (a volume mark by default) on a sharp accent tile.
103
+ app_mark = ft.Container(
104
+ width=30,
105
+ height=30,
106
+ border_radius=2,
107
+ bgcolor=ft.Colors.PRIMARY,
108
+ alignment=ft.Alignment.CENTER,
109
+ content=ft.Icon(lab.icon or ft.Icons.VIEW_IN_AR, size=18, color=ft.Colors.ON_PRIMARY),
110
+ )
111
+
112
+ # Thin header strip: mark and tracked mono-caps title on the left, a
113
+ # factual mono readout (the registered worker) on the right.
114
+ top_bar = ft.Container(
115
+ padding=ft.Padding.symmetric(horizontal=20, vertical=10),
116
+ bgcolor=ft.Colors.SURFACE_CONTAINER_LOWEST,
117
+ content=ft.Row(
118
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
119
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
120
+ controls=[
121
+ ft.Row(
122
+ spacing=12,
123
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
124
+ controls=[
125
+ app_mark,
126
+ ft.Text(
127
+ lab.title.upper(),
128
+ style=ft.TextStyle(
129
+ size=14,
130
+ weight=ft.FontWeight.W_700,
131
+ letter_spacing=3,
132
+ font_family=ui.FONT_MONO,
133
+ ),
134
+ ),
135
+ ],
136
+ ),
137
+ ft.Text(
138
+ f"WORKER · {lab.worker.func.__name__.upper()}",
139
+ style=ft.TextStyle(
140
+ size=11,
141
+ letter_spacing=2,
142
+ font_family=ui.FONT_MONO,
143
+ color=ft.Colors.ON_SURFACE_VARIANT,
144
+ ),
145
+ ),
146
+ ],
147
+ ),
148
+ )
149
+
150
+ if layout == "scroll":
151
+ # One continuous page under the top bar; no rail, no navigation.
152
+ body = ft.Container(expand=True, padding=24, content=scroll.build(state, page))
153
+ else:
154
+ # Right-hand pane; its single child is swapped on navigation.
155
+ content = ft.Container(expand=True, padding=24)
156
+
157
+ def show_page(index):
158
+ # Rebuild-on-navigate: build fresh so the page re-reads LabState.
159
+ content.content = PAGES[index][2](state, page)
160
+
161
+ def on_nav_change(e):
162
+ show_page(e.control.selected_index)
163
+ page.update()
164
+
165
+ # Spec-sheet rail: numbered mono-caps labels, a squared indicator.
166
+ rail = ft.NavigationRail(
167
+ selected_index=0,
168
+ label_type=ft.NavigationRailLabelType.ALL,
169
+ min_width=100,
170
+ bgcolor=ft.Colors.SURFACE_CONTAINER_LOWEST,
171
+ indicator_color=ft.Colors.PRIMARY_CONTAINER,
172
+ indicator_shape=ft.RoundedRectangleBorder(radius=2),
173
+ selected_label_text_style=ft.TextStyle(
174
+ size=11,
175
+ weight=ft.FontWeight.W_700,
176
+ letter_spacing=1.5,
177
+ font_family=ui.FONT_MONO,
178
+ color=ft.Colors.PRIMARY,
179
+ ),
180
+ unselected_label_text_style=ft.TextStyle(
181
+ size=11,
182
+ letter_spacing=1.5,
183
+ font_family=ui.FONT_MONO,
184
+ color=ft.Colors.ON_SURFACE_VARIANT,
185
+ ),
186
+ on_change=on_nav_change,
187
+ destinations=[
188
+ ft.NavigationRailDestination(
189
+ icon=icon, label=f"{index + 1:02d} {label.upper()}"
190
+ )
191
+ for index, (label, icon, _) in enumerate(PAGES)
192
+ ],
193
+ )
194
+
195
+ # Render the first page before mounting, so no handler fires against
196
+ # an unattached tree.
197
+ show_page(0)
198
+
199
+ body = ft.Row(
200
+ expand=True,
201
+ controls=[
202
+ rail,
203
+ ft.VerticalDivider(width=1, color=ft.Colors.OUTLINE_VARIANT),
204
+ content,
205
+ ],
206
+ )
207
+
208
+ page.add(
209
+ ft.Column(
210
+ expand=True,
211
+ spacing=0,
212
+ controls=[
213
+ top_bar,
214
+ ft.Divider(height=1, color=ft.Colors.OUTLINE_VARIANT),
215
+ body,
216
+ ],
217
+ )
218
+ )
219
+
220
+ return main
labforge/state.py ADDED
@@ -0,0 +1,74 @@
1
+ """
2
+ LabState: the mutable workspace every page of a running app shares.
3
+
4
+ Pages are rebuilt fresh on each navigation and the state has no observers, so
5
+ anything that must survive a rebuild lives here.
6
+ """
7
+
8
+ import time
9
+ from dataclasses import dataclass, field
10
+
11
+ from .scan import ScanResult, run_worker
12
+
13
+
14
+ @dataclass
15
+ class LabState:
16
+ """
17
+ The mutable workspace of a running Lab.
18
+
19
+ Parameters
20
+ ----------
21
+ lab: Lab
22
+ The registrations; read-only from pages.
23
+ worker_values: dict
24
+ Current parsed control values; list-valued entries are scan axes.
25
+ data: object
26
+ The last run's output — the worker's bare return, or a ScanResult.
27
+ n_points: int
28
+ Grid size of the last run (1 for a scalar run, 0 before any run).
29
+ run_summary: str
30
+ Status line shown on the Simulation page.
31
+ viz_values: dict
32
+ {viz title: {kwarg: value}}, persisted across page rebuilds.
33
+ analysis_values: dict
34
+ {analysis title: {kwarg: value}}, persisted across page rebuilds.
35
+ """
36
+
37
+ lab: object
38
+ worker_values: dict = None
39
+ data: object = None
40
+ n_points: int = 0
41
+ run_summary: str = ""
42
+ viz_values: dict = field(default_factory=dict)
43
+ analysis_values: dict = field(default_factory=dict)
44
+
45
+ def __post_init__(self):
46
+ # Seed every control value from its normalized Param default.
47
+ if self.worker_values is None:
48
+ self.worker_values = defaults(self.lab.worker.params)
49
+ for entry in self.lab.vizzes:
50
+ self.viz_values.setdefault(entry.title, defaults(entry.params))
51
+ for entry in self.lab.analyses:
52
+ self.analysis_values.setdefault(entry.title, defaults(entry.params))
53
+
54
+ def has_data(self):
55
+ """True once the worker has produced data; gates Viz and Analysis."""
56
+ return self.n_points > 0
57
+
58
+ def run(self):
59
+ """Run the worker over the current values and record a telemetry line."""
60
+ start = time.perf_counter()
61
+ self.data = run_worker(self.lab.worker.func, self.worker_values)
62
+ self.n_points = len(self.data) if isinstance(self.data, ScanResult) else 1
63
+ elapsed = time.perf_counter() - start
64
+ # Report the grid the author actually asked for, not just the point count.
65
+ axes = sum(1 for value in self.worker_values.values() if isinstance(value, list))
66
+ grid = f"{self.n_points} {'POINT' if self.n_points == 1 else 'POINTS'}"
67
+ if axes:
68
+ grid += f" · {axes} {'AXIS' if axes == 1 else 'AXES'}"
69
+ self.run_summary = f"RUN COMPLETE · {grid} · {elapsed:.2f} s"
70
+
71
+
72
+ def defaults(params):
73
+ """Initial control values for a normalized {name: Param} spec."""
74
+ return {name: param.default for name, param in params.items()}
labforge/theme.py ADDED
@@ -0,0 +1,323 @@
1
+ """
2
+ The colour table — chrome, plot palette and equation ink — and the one
3
+ process-wide selection of it.
4
+
5
+ Holding all three in one frozen value is what keeps them from drifting apart.
6
+ Pure hexes, no Flet import: shell.py owns the mapping onto ColorScheme slots, so
7
+ figures.py and mathtext.py read the palette without pulling in the UI layer.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Theme:
15
+ """
16
+ Every colour the app draws, chrome and plots together.
17
+
18
+ Parameters
19
+ ----------
20
+ name: str
21
+ The key open(theme=...) selects this theme by.
22
+ note: str
23
+ One-line description, shown by names().
24
+ mode: str
25
+ "dark" or "light"; drives the Flet ThemeMode so widget defaults the
26
+ scheme does not name (shadows, ripples, error tones) match the surfaces.
27
+ accent: str
28
+ The single electric colour reserved for interactive and live things.
29
+ on_accent: str
30
+ Ink drawn on top of accent fills; carries the accent's contrast burden.
31
+ accent_dim: str
32
+ Muted accent fill for selected-but-not-active chrome (the rail indicator).
33
+ on_accent_dim: str
34
+ Ink on accent_dim.
35
+ surface: str
36
+ The page background.
37
+ surface_lowest: str
38
+ Recessed chrome — the top bar and rail.
39
+ surface_low: str
40
+ The lowest raised container step.
41
+ surface_container: str
42
+ The standard card fill.
43
+ surface_high: str
44
+ A raised card step.
45
+ surface_highest: str
46
+ The most raised step; input fills.
47
+ on_surface: str
48
+ Body ink.
49
+ on_surface_variant: str
50
+ Secondary ink — labels, readouts, anything factual but not primary.
51
+ outline: str
52
+ Borders that must read as a boundary.
53
+ outline_variant: str
54
+ Hairline dividers that must not.
55
+ data: str
56
+ Plot colour for the worker's data — matches accent, so a figure reads as
57
+ part of the app.
58
+ model: str
59
+ Plot colour for a model or comparison curve; contrasts with data.
60
+ highlight: str
61
+ Third plot colour, for an emphasised subset.
62
+ ink: str
63
+ Plot and equation ink. Sits on a transparent PNG over surface, so it
64
+ tracks on_surface rather than being pure white.
65
+ grid: str
66
+ Plot grid and spines.
67
+ """
68
+
69
+ name: str
70
+ note: str
71
+ mode: str
72
+ accent: str
73
+ on_accent: str
74
+ accent_dim: str
75
+ on_accent_dim: str
76
+ surface: str
77
+ surface_lowest: str
78
+ surface_low: str
79
+ surface_container: str
80
+ surface_high: str
81
+ surface_highest: str
82
+ on_surface: str
83
+ on_surface_variant: str
84
+ outline: str
85
+ outline_variant: str
86
+ data: str
87
+ model: str
88
+ highlight: str
89
+ ink: str
90
+ grid: str
91
+
92
+
93
+ THEMES = {
94
+ "instrument": Theme(
95
+ name="instrument",
96
+ note="Near-black teal-tinted surfaces, one electric accent.",
97
+ mode="dark",
98
+ accent="#2BE4C8",
99
+ on_accent="#04211C",
100
+ accent_dim="#123B33",
101
+ on_accent_dim="#7FEFD9",
102
+ surface="#0B0F10",
103
+ surface_lowest="#080C0D",
104
+ surface_low="#11181A",
105
+ surface_container="#141C1E",
106
+ surface_high="#182123",
107
+ surface_highest="#1C2628",
108
+ on_surface="#E4EBEA",
109
+ on_surface_variant="#8CA0A0",
110
+ outline="#3C4C4E",
111
+ outline_variant="#233032",
112
+ data="#2BE4C8",
113
+ model="#FF4D9E",
114
+ highlight="#7FEFD9",
115
+ ink="#D9E2E0",
116
+ grid="#3A4649",
117
+ ),
118
+ "neon_violet": Theme(
119
+ name="neon_violet",
120
+ mode="dark",
121
+ note="Violet chrome with hot-pink data on black.",
122
+ accent="#9929EA",
123
+ on_accent="#FFFFFF",
124
+ accent_dim="#2E1147",
125
+ on_accent_dim="#D9A6FF",
126
+ surface="#000000",
127
+ surface_lowest="#0A0410",
128
+ surface_low="#120818",
129
+ surface_container="#170A22",
130
+ surface_high="#1E0E2C",
131
+ surface_highest="#251236",
132
+ on_surface="#F3EAFB",
133
+ on_surface_variant="#A98BC4",
134
+ outline="#3B1B57",
135
+ outline_variant="#26113A",
136
+ data="#FF5FCF",
137
+ model="#FAEB92",
138
+ highlight="#9929EA",
139
+ ink="#F3EAFB",
140
+ grid="#3B1B57",
141
+ ),
142
+ "neon_gold": Theme(
143
+ name="neon_gold",
144
+ mode="dark",
145
+ note="The violet palette with pale gold as the accent instead.",
146
+ accent="#FAEB92",
147
+ on_accent="#1A0B26",
148
+ accent_dim="#3E3418",
149
+ on_accent_dim="#FAEB92",
150
+ surface="#000000",
151
+ surface_lowest="#0C0610",
152
+ surface_low="#150823",
153
+ surface_container="#1A0B26",
154
+ surface_high="#221030",
155
+ surface_highest="#29143A",
156
+ on_surface="#F6EEFC",
157
+ on_surface_variant="#AE90C8",
158
+ outline="#43205F",
159
+ outline_variant="#2B1440",
160
+ data="#FF5FCF",
161
+ model="#9929EA",
162
+ highlight="#FAEB92",
163
+ ink="#F6EEFC",
164
+ grid="#43205F",
165
+ ),
166
+ "retro_green": Theme(
167
+ name="retro_green",
168
+ mode="dark",
169
+ note="Matrix mood with a calmer green and a legible olive card.",
170
+ accent="#08CB00",
171
+ on_accent="#00230A",
172
+ accent_dim="#12400A",
173
+ on_accent_dim="#7CFF77",
174
+ surface="#000000",
175
+ surface_lowest="#050805",
176
+ surface_low="#1B2A00",
177
+ surface_container="#253900",
178
+ surface_high="#2E4600",
179
+ surface_highest="#375300",
180
+ on_surface="#EEEEEE",
181
+ on_surface_variant="#8FA07A",
182
+ outline="#3A5410",
183
+ outline_variant="#223300",
184
+ data="#08CB00",
185
+ model="#EEEEEE",
186
+ highlight="#7CFF77",
187
+ ink="#EEEEEE",
188
+ grid="#3A5410",
189
+ ),
190
+ "paper": Theme(
191
+ name="paper",
192
+ note="Warm paper surfaces, graphite ink and a vermilion accent.",
193
+ mode="light",
194
+ accent="#C14B2E",
195
+ on_accent="#FFF6F1",
196
+ accent_dim="#F2D9CE",
197
+ on_accent_dim="#8A2F1B",
198
+ surface="#FAF6EF",
199
+ surface_lowest="#F1EAD9",
200
+ surface_low="#FFFFFF",
201
+ surface_container="#FFFDF8",
202
+ surface_high="#FFFFFF",
203
+ surface_highest="#FFFFFF",
204
+ on_surface="#33302A",
205
+ on_surface_variant="#7A7264",
206
+ outline="#C9BFA9",
207
+ outline_variant="#E5DECC",
208
+ data="#C14B2E",
209
+ model="#2E6F8E",
210
+ highlight="#E0A458",
211
+ ink="#33302A",
212
+ grid="#C9BFA9",
213
+ ),
214
+ "mint": Theme(
215
+ name="mint",
216
+ note="Light mint greens on white, colorhunt's fresh-green family.",
217
+ mode="light",
218
+ accent="#3E8E5A",
219
+ on_accent="#F2FBF4",
220
+ accent_dim="#D8F0DC",
221
+ on_accent_dim="#2C6E44",
222
+ surface="#F4FAEF",
223
+ surface_lowest="#E4F3DA",
224
+ surface_low="#FFFFFF",
225
+ surface_container="#FBFEF8",
226
+ surface_high="#FFFFFF",
227
+ surface_highest="#FFFFFF",
228
+ on_surface="#24312A",
229
+ on_surface_variant="#6D8072",
230
+ outline="#B9CDB9",
231
+ outline_variant="#DCE9D8",
232
+ data="#3E8E5A",
233
+ model="#B85B9E",
234
+ highlight="#7FBF6C",
235
+ ink="#24312A",
236
+ grid="#B9CDB9",
237
+ ),
238
+ "glacier": Theme(
239
+ name="glacier",
240
+ note="Pale glacier blues with a deep slate accent.",
241
+ mode="light",
242
+ accent="#4A628A",
243
+ on_accent="#F2F7FF",
244
+ accent_dim="#D5E4EE",
245
+ on_accent_dim="#35577D",
246
+ surface="#F2F8FA",
247
+ surface_lowest="#E0EEF2",
248
+ surface_low="#FFFFFF",
249
+ surface_container="#FAFDFE",
250
+ surface_high="#FFFFFF",
251
+ surface_highest="#FFFFFF",
252
+ on_surface="#22303B",
253
+ on_surface_variant="#64798A",
254
+ outline="#B4C8D4",
255
+ outline_variant="#DCE8EE",
256
+ data="#3D7EA6",
257
+ model="#C96A4A",
258
+ highlight="#4A628A",
259
+ ink="#22303B",
260
+ grid="#B4C8D4",
261
+ ),
262
+ "lavender": Theme(
263
+ name="lavender",
264
+ note="Soft lavender neutrals with a deep violet accent.",
265
+ mode="light",
266
+ accent="#7C6BD6",
267
+ on_accent="#F8F6FF",
268
+ accent_dim="#E3DBF7",
269
+ on_accent_dim="#5A4BB0",
270
+ surface="#F7F4FC",
271
+ surface_lowest="#EDE6F7",
272
+ surface_low="#FFFFFF",
273
+ surface_container="#FCFAFF",
274
+ surface_high="#FFFFFF",
275
+ surface_highest="#FFFFFF",
276
+ on_surface="#2C2838",
277
+ on_surface_variant="#756E88",
278
+ outline="#C4BBDA",
279
+ outline_variant="#E4DEF1",
280
+ data="#7C6BD6",
281
+ model="#D66B8F",
282
+ highlight="#A594F9",
283
+ ink="#2C2838",
284
+ grid="#C4BBDA",
285
+ ),
286
+ }
287
+
288
+ DEFAULT = "paper"
289
+
290
+ # The process-wide selection, rebound only by use() before the app serves. This
291
+ # is the one piece of module state the per-session rule tolerates: Flet calls
292
+ # main(page) per browser connection, but every session resolves the same theme,
293
+ # so it is config rather than shared mutable state.
294
+ ACTIVE = THEMES[DEFAULT]
295
+
296
+
297
+ def names():
298
+ """The registered themes as {name: note}, in registration order."""
299
+ return {name: theme.note for name, theme in THEMES.items()}
300
+
301
+
302
+ def use(name):
303
+ """
304
+ Select the process-wide theme by name and return it.
305
+
306
+ Called once from shell.build_main before the app serves; see ACTIVE on why
307
+ process-wide state is safe here.
308
+ """
309
+ global ACTIVE
310
+ if name not in THEMES:
311
+ raise ValueError(f"theme must be one of {sorted(THEMES)}, got {name!r}.")
312
+ ACTIVE = THEMES[name]
313
+ return ACTIVE
314
+
315
+
316
+ def active():
317
+ """
318
+ The active Theme: the palette figures and equations render with.
319
+
320
+ Read at draw time, never bound at import, so a figure follows whichever
321
+ theme open(theme=...) selected.
322
+ """
323
+ return ACTIVE