Skip to main content

max / alloy_tui

Scaffold alloy_ui crate Workspace + first crate. tokens.toml is the build-time input; build.rs parses it, converts OKLCH to sRGB via palette, and emits src/generated/tokens.rs with typed constants. docs/TOKENS.md remains canonical upstream of the TOML. - crates/alloy_ui/tokens.toml mirrors TOKENS.md verbatim - src/theme.rs defines apply_alloy_visuals(ctx, Mode) and threads the ramp + geometry into egui::Visuals - src/widgets.rs ships the smallest useful primitive set: function (primary_button), HOFs (section, field_row), impl Widget (SeverityTag), and an explicit focus_ring helper - examples/showcase renders the ramp, accent palette, and a simple form to verify tokens visually; light/dark toggle exercises both derivations Cargo.lock is committed (crate has a binary example).
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-26 19:16 UTC
Signed with PGP, not checked
Commit: 0330be6f6ae4c94e3bd4e9ff8f44fdbe2e9e84b4
Parent: a855286
12 files changed, +1229 insertions, -1 deletion
M .gitignore +3
@@ -2,6 +2,9 @@
2 2 **/*.rs.bk
3 3 Cargo.lock.bak
4 4
5 + # Generated by alloy_ui/build.rs from tokens.toml.
6 + crates/alloy_ui/src/generated/tokens.rs
7 +
5 8 # Build/dist outputs
6 9 /dist/build/
7 10 /dist/*.iso
M docs/todo.md +1 -1
@@ -19,7 +19,7 @@
19 19 - [ ] Audit Departure's unicode coverage; document the fallback chain (Iosevka) for out-of-range code points.
20 20 - [x] Set up the repo: git init, license, contribution model. → **GPLv3-or-later** (deliberate pick; egui is MIT/Apache so the choice was open). [LICENSE](../LICENSE), [CONTRIBUTING.md](../CONTRIBUTING.md).
21 21 - [ ] Branding assets: wordmark/logotype, repo social card, favicon. Deferred until first user-facing surface ships.
22 - - [ ] Scaffold the Alloy egui design-system crate (themed widgets, extension traits on `egui::Ui`, palette + typography constants).
22 + - [x] Scaffold the Alloy egui design-system crate. → `crates/alloy_ui/` with `tokens.toml` → `build.rs` → `src/generated/tokens.rs` pipeline, `apply_alloy_visuals`, starter primitives (`primary_button`, `section`, `field_row`, `SeverityTag`, `focus_ring`), and an `examples/showcase` binary. Widget set is intentionally minimal — grow it as marquee apps need it, not preemptively.
23 23 - [ ] Pick the first concrete deliverable to build (lockscreen v1 or package GUI — both are listed as egui apps; sequencing matters).
24 24
25 25 ## v0 stack to package and document
A Cargo.lock +500
@@ -1,0 +1,2928 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "accesskit"
7 + version = "0.24.1"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
10 + dependencies = [
11 + "uuid",
12 + ]
13 +
14 + [[package]]
15 + name = "adler2"
16 + version = "2.0.1"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19 +
20 + [[package]]
21 + name = "ahash"
22 + version = "0.8.12"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
25 + dependencies = [
26 + "cfg-if",
27 + "once_cell",
28 + "version_check",
29 + "zerocopy",
30 + ]
31 +
32 + [[package]]
33 + name = "alloy_ui"
34 + version = "0.0.0"
35 + dependencies = [
36 + "eframe",
37 + "egui",
38 + "palette",
39 + "serde",
40 + "toml",
41 + ]
42 +
43 + [[package]]
44 + name = "android-activity"
45 + version = "0.6.1"
46 + source = "registry+https://github.com/rust-lang/crates.io-index"
47 + checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
48 + dependencies = [
49 + "android-properties",
50 + "bitflags 2.13.0",
51 + "cc",
52 + "jni",
53 + "libc",
54 + "log",
55 + "ndk",
56 + "ndk-context",
57 + "ndk-sys",
58 + "num_enum",
59 + "thiserror 2.0.18",
60 + ]
61 +
62 + [[package]]
63 + name = "android-properties"
64 + version = "0.2.2"
65 + source = "registry+https://github.com/rust-lang/crates.io-index"
66 + checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
67 +
68 + [[package]]
69 + name = "approx"
70 + version = "0.5.1"
71 + source = "registry+https://github.com/rust-lang/crates.io-index"
72 + checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
73 + dependencies = [
74 + "num-traits",
75 + ]
76 +
77 + [[package]]
78 + name = "arboard"
79 + version = "3.6.1"
80 + source = "registry+https://github.com/rust-lang/crates.io-index"
81 + checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
82 + dependencies = [
83 + "clipboard-win",
84 + "image",
85 + "log",
86 + "objc2 0.6.4",
87 + "objc2-app-kit 0.3.2",
88 + "objc2-core-foundation",
89 + "objc2-core-graphics",
90 + "objc2-foundation 0.3.2",
91 + "parking_lot",
92 + "percent-encoding",
93 + "windows-sys 0.60.2",
94 + "x11rb",
95 + ]
96 +
97 + [[package]]
98 + name = "arrayvec"
99 + version = "0.7.7"
100 + source = "registry+https://github.com/rust-lang/crates.io-index"
101 + checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
102 +
103 + [[package]]
104 + name = "atomic-waker"
105 + version = "1.1.2"
106 + source = "registry+https://github.com/rust-lang/crates.io-index"
107 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
108 +
109 + [[package]]
110 + name = "autocfg"
111 + version = "1.5.1"
112 + source = "registry+https://github.com/rust-lang/crates.io-index"
113 + checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
114 +
115 + [[package]]
116 + name = "bitflags"
117 + version = "1.3.2"
118 + source = "registry+https://github.com/rust-lang/crates.io-index"
119 + checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
120 +
121 + [[package]]
122 + name = "bitflags"
123 + version = "2.13.0"
124 + source = "registry+https://github.com/rust-lang/crates.io-index"
125 + checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
126 +
127 + [[package]]
128 + name = "block2"
129 + version = "0.5.1"
130 + source = "registry+https://github.com/rust-lang/crates.io-index"
131 + checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
132 + dependencies = [
133 + "objc2 0.5.2",
134 + ]
135 +
136 + [[package]]
137 + name = "block2"
138 + version = "0.6.2"
139 + source = "registry+https://github.com/rust-lang/crates.io-index"
140 + checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
141 + dependencies = [
142 + "objc2 0.6.4",
143 + ]
144 +
145 + [[package]]
146 + name = "bumpalo"
147 + version = "3.20.3"
148 + source = "registry+https://github.com/rust-lang/crates.io-index"
149 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
150 +
151 + [[package]]
152 + name = "by_address"
153 + version = "1.2.1"
154 + source = "registry+https://github.com/rust-lang/crates.io-index"
155 + checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
156 +
157 + [[package]]
158 + name = "bytemuck"
159 + version = "1.25.0"
160 + source = "registry+https://github.com/rust-lang/crates.io-index"
161 + checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
162 + dependencies = [
163 + "bytemuck_derive",
164 + ]
165 +
166 + [[package]]
167 + name = "bytemuck_derive"
168 + version = "1.10.2"
169 + source = "registry+https://github.com/rust-lang/crates.io-index"
170 + checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
171 + dependencies = [
172 + "proc-macro2",
173 + "quote",
174 + "syn",
175 + ]
176 +
177 + [[package]]
178 + name = "byteorder-lite"
179 + version = "0.1.0"
180 + source = "registry+https://github.com/rust-lang/crates.io-index"
181 + checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
182 +
183 + [[package]]
184 + name = "bytes"
185 + version = "1.12.0"
186 + source = "registry+https://github.com/rust-lang/crates.io-index"
187 + checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
188 +
189 + [[package]]
190 + name = "calloop"
191 + version = "0.13.0"
192 + source = "registry+https://github.com/rust-lang/crates.io-index"
193 + checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
194 + dependencies = [
195 + "bitflags 2.13.0",
196 + "log",
197 + "polling",
198 + "rustix 0.38.44",
199 + "slab",
200 + "thiserror 1.0.69",
201 + ]
202 +
203 + [[package]]
204 + name = "calloop"
205 + version = "0.14.4"
206 + source = "registry+https://github.com/rust-lang/crates.io-index"
207 + checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
208 + dependencies = [
209 + "bitflags 2.13.0",
210 + "polling",
211 + "rustix 1.1.4",
212 + "slab",
213 + "tracing",
214 + ]
215 +
216 + [[package]]
217 + name = "calloop-wayland-source"
218 + version = "0.4.1"
219 + source = "registry+https://github.com/rust-lang/crates.io-index"
220 + checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
221 + dependencies = [
222 + "calloop 0.14.4",
223 + "rustix 1.1.4",
224 + "wayland-backend",
225 + "wayland-client",
226 + ]
227 +
228 + [[package]]
229 + name = "cc"
230 + version = "1.2.65"
231 + source = "registry+https://github.com/rust-lang/crates.io-index"
232 + checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
233 + dependencies = [
234 + "find-msvc-tools",
235 + "jobserver",
236 + "libc",
237 + "shlex",
238 + ]
239 +
240 + [[package]]
241 + name = "cfg-if"
242 + version = "1.0.4"
243 + source = "registry+https://github.com/rust-lang/crates.io-index"
244 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
245 +
246 + [[package]]
247 + name = "cfg_aliases"
248 + version = "0.2.1"
249 + source = "registry+https://github.com/rust-lang/crates.io-index"
250 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
251 +
252 + [[package]]
253 + name = "cgl"
254 + version = "0.3.2"
255 + source = "registry+https://github.com/rust-lang/crates.io-index"
256 + checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
257 + dependencies = [
258 + "libc",
259 + ]
260 +
261 + [[package]]
262 + name = "clipboard-win"
263 + version = "5.4.1"
264 + source = "registry+https://github.com/rust-lang/crates.io-index"
265 + checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
266 + dependencies = [
267 + "error-code",
268 + ]
269 +
270 + [[package]]
271 + name = "color"
272 + version = "0.3.3"
273 + source = "registry+https://github.com/rust-lang/crates.io-index"
274 + checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
275 + dependencies = [
276 + "bytemuck",
277 + ]
278 +
279 + [[package]]
280 + name = "combine"
281 + version = "4.6.7"
282 + source = "registry+https://github.com/rust-lang/crates.io-index"
283 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
284 + dependencies = [
285 + "bytes",
286 + "memchr",
287 + ]
288 +
289 + [[package]]
290 + name = "concurrent-queue"
291 + version = "2.5.0"
292 + source = "registry+https://github.com/rust-lang/crates.io-index"
293 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
294 + dependencies = [
295 + "crossbeam-utils",
296 + ]
297 +
298 + [[package]]
299 + name = "core-foundation"
300 + version = "0.9.4"
301 + source = "registry+https://github.com/rust-lang/crates.io-index"
302 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
303 + dependencies = [
304 + "core-foundation-sys",
305 + "libc",
306 + ]
307 +
308 + [[package]]
309 + name = "core-foundation"
310 + version = "0.10.1"
311 + source = "registry+https://github.com/rust-lang/crates.io-index"
312 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
313 + dependencies = [
314 + "core-foundation-sys",
315 + "libc",
316 + ]
317 +
318 + [[package]]
319 + name = "core-foundation-sys"
320 + version = "0.8.7"
321 + source = "registry+https://github.com/rust-lang/crates.io-index"
322 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
323 +
324 + [[package]]
325 + name = "core-graphics"
326 + version = "0.23.2"
327 + source = "registry+https://github.com/rust-lang/crates.io-index"
328 + checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
329 + dependencies = [
330 + "bitflags 1.3.2",
331 + "core-foundation 0.9.4",
332 + "core-graphics-types",
333 + "foreign-types",
334 + "libc",
335 + ]
336 +
337 + [[package]]
338 + name = "core-graphics-types"
339 + version = "0.1.3"
340 + source = "registry+https://github.com/rust-lang/crates.io-index"
341 + checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
342 + dependencies = [
343 + "bitflags 1.3.2",
344 + "core-foundation 0.9.4",
345 + "libc",
346 + ]
347 +
348 + [[package]]
349 + name = "crc32fast"
350 + version = "1.5.0"
351 + source = "registry+https://github.com/rust-lang/crates.io-index"
352 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
353 + dependencies = [
354 + "cfg-if",
355 + ]
356 +
357 + [[package]]
358 + name = "crossbeam-utils"
359 + version = "0.8.21"
360 + source = "registry+https://github.com/rust-lang/crates.io-index"
361 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
362 +
363 + [[package]]
364 + name = "crunchy"
365 + version = "0.2.4"
366 + source = "registry+https://github.com/rust-lang/crates.io-index"
367 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
368 +
369 + [[package]]
370 + name = "cursor-icon"
371 + version = "1.2.0"
372 + source = "registry+https://github.com/rust-lang/crates.io-index"
373 + checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
374 +
375 + [[package]]
376 + name = "dispatch"
377 + version = "0.2.0"
378 + source = "registry+https://github.com/rust-lang/crates.io-index"
379 + checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
380 +
381 + [[package]]
382 + name = "dispatch2"
383 + version = "0.3.1"
384 + source = "registry+https://github.com/rust-lang/crates.io-index"
385 + checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
386 + dependencies = [
387 + "bitflags 2.13.0",
388 + "objc2 0.6.4",
389 + ]
390 +
391 + [[package]]
392 + name = "displaydoc"
393 + version = "0.2.6"
394 + source = "registry+https://github.com/rust-lang/crates.io-index"
395 + checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
396 + dependencies = [
397 + "proc-macro2",
398 + "quote",
399 + "syn",
400 + ]
401 +
402 + [[package]]
403 + name = "dlib"
404 + version = "0.5.3"
405 + source = "registry+https://github.com/rust-lang/crates.io-index"
406 + checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
407 + dependencies = [
408 + "libloading",
409 + ]
410 +
411 + [[package]]
412 + name = "document-features"
413 + version = "0.2.12"
414 + source = "registry+https://github.com/rust-lang/crates.io-index"
415 + checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
416 + dependencies = [
417 + "litrs",
418 + ]
419 +
420 + [[package]]
421 + name = "downcast-rs"
422 + version = "1.2.1"
423 + source = "registry+https://github.com/rust-lang/crates.io-index"
424 + checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
425 +
426 + [[package]]
427 + name = "dpi"
428 + version = "0.1.2"
429 + source = "registry+https://github.com/rust-lang/crates.io-index"
430 + checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
431 +
432 + [[package]]
433 + name = "ecolor"
434 + version = "0.34.3"
435 + source = "registry+https://github.com/rust-lang/crates.io-index"
436 + checksum = "a05fbfa222ffb51989d5ccf33e5f7aebfcf96c5023413856b0c3618a7f79896e"
437 + dependencies = [
438 + "bytemuck",
439 + "emath",
440 + ]
441 +
442 + [[package]]
443 + name = "eframe"
444 + version = "0.34.3"
445 + source = "registry+https://github.com/rust-lang/crates.io-index"
446 + checksum = "f98fe83b2589105b69dd25ca1e0fa2135a6e864d502fd8e08978f937e128cfef"
447 + dependencies = [
448 + "ahash",
449 + "bytemuck",
450 + "document-features",
451 + "egui",
452 + "egui-winit",
453 + "egui_glow",
454 + "glow",
455 + "glutin",
456 + "glutin-winit",
457 + "image",
458 + "js-sys",
459 + "log",
460 + "objc2 0.6.4",
461 + "objc2-app-kit 0.3.2",
462 + "objc2-foundation 0.3.2",
463 + "parking_lot",
464 + "percent-encoding",
465 + "profiling",
466 + "raw-window-handle",
467 + "static_assertions",
468 + "wasm-bindgen",
469 + "wasm-bindgen-futures",
470 + "web-sys",
471 + "web-time",
472 + "windows-sys 0.61.2",
473 + "winit",
474 + ]
475 +
476 + [[package]]
477 + name = "egui"
478 + version = "0.34.3"
479 + source = "registry+https://github.com/rust-lang/crates.io-index"
480 + checksum = "42112be0ae157289312b92b3dfaf20e911b5a3c4c65d4aab0e7c47fbc0ce16e3"
481 + dependencies = [
482 + "accesskit",
483 + "ahash",
484 + "bitflags 2.13.0",
485 + "emath",
486 + "epaint",
487 + "log",
488 + "nohash-hasher",
489 + "profiling",
490 + "smallvec",
491 + "unicode-segmentation",
492 + ]
493 +
494 + [[package]]
495 + name = "egui-winit"
496 + version = "0.34.3"
497 + source = "registry+https://github.com/rust-lang/crates.io-index"
498 + checksum = "967c5b323625d46d46a59b5daba3fef742248d27693cc18972458619858c4239"
499 + dependencies = [
500 + "arboard",
Lines truncated
A Cargo.toml +21
@@ -1,0 +1,21 @@
1 + [workspace]
2 + resolver = "3"
3 + members = ["crates/*"]
4 +
5 + [workspace.package]
6 + edition = "2024"
7 + rust-version = "1.86"
8 + license = "GPL-3.0-or-later"
9 + repository = "https://github.com/maxjmath/alloy"
10 + authors = ["Max Johnson <me@maxj.phd>"]
11 +
12 + [workspace.dependencies]
13 + egui = { version = "0.34", default-features = false, features = ["default_fonts"] }
14 + eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
15 + toml = "0.8"
16 + palette = { version = "0.7", default-features = false, features = ["std"] }
17 +
18 + [profile.release]
19 + lto = "thin"
20 + codegen-units = 1
21 + strip = "symbols"
@@ -1,0 +1,24 @@
1 + [package]
2 + name = "alloy_ui"
3 + version = "0.0.0"
4 + description = "Alloy design system: tokens, themed primitives, and layout combinators for egui."
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + license.workspace = true
8 + repository.workspace = true
9 + authors.workspace = true
10 +
11 + [dependencies]
12 + egui.workspace = true
13 +
14 + [build-dependencies]
15 + toml = { workspace = true }
16 + palette = { workspace = true }
17 + serde = { version = "1", features = ["derive"] }
18 +
19 + [dev-dependencies]
20 + eframe.workspace = true
21 +
22 + [[example]]
23 + name = "showcase"
24 + path = "examples/showcase.rs"
@@ -1,0 +1,191 @@
1 + //! Compile `tokens.toml` into `src/generated/tokens.rs`.
2 + //!
3 + //! OKLCH colors are converted to gamma-encoded sRGB via the `palette` crate.
4 + //! The output file is committed-but-regenerated: it lives under `src/generated/`
5 + //! so `rust-analyzer` can see it without a full build, but `cargo build` always
6 + //! rewrites it from the TOML so the TOML is the only authority.
7 +
8 + use std::env;
9 + use std::fmt::Write as _;
10 + use std::fs;
11 + use std::path::PathBuf;
12 +
13 + use palette::{FromColor, Oklch, Srgb};
14 + use serde::Deserialize;
15 +
16 + #[derive(Deserialize)]
17 + struct Tokens {
18 + tint: Tint,
19 + ramp: Ramp,
20 + accents: std::collections::BTreeMap<String, Oklch3>,
21 + typography: Typography,
22 + geometry: Geometry,
23 + }
24 +
25 + #[derive(Deserialize)]
26 + struct Tint {
27 + hue: f32,
28 + chroma: f32,
29 + }
30 +
31 + #[derive(Deserialize)]
32 + struct Ramp {
33 + light: std::collections::BTreeMap<String, f32>,
34 + dark: std::collections::BTreeMap<String, f32>,
35 + }
36 +
37 + #[derive(Deserialize)]
38 + struct Oklch3 {
39 + l: f32,
40 + c: f32,
41 + h: f32,
42 + }
43 +
44 + #[derive(Deserialize)]
45 + struct Typography {
46 + font_mono_working: String,
47 + font_mono_working_noligatures: String,
48 + font_mono_display: String,
49 + size_caption: f32,
50 + size_body: f32,
51 + size_section_header: f32,
52 + size_title: f32,
53 + size_readout_sm: f32,
54 + size_readout_md: f32,
55 + size_readout_lg: f32,
56 + size_readout_xl: f32,
57 + weight_body: u32,
58 + weight_emphasis: u32,
59 + tracking_body: f32,
60 + tracking_display: f32,
61 + tracking_display_caps: f32,
62 + line_height_body: f32,
63 + line_height_tabular: f32,
64 + line_height_display: f32,
65 + }
66 +
67 + #[derive(Deserialize)]
68 + struct Geometry {
69 + space_xs: f32,
70 + space_sm: f32,
71 + space_md: f32,
72 + space_lg: f32,
73 + space_xl: f32,
74 + radius_control: f32,
75 + radius_overlay: f32,
76 + radius_data: f32,
77 + border_width_default: f32,
78 + border_width_focus: f32,
79 + focus_ring_offset: f32,
80 + }
81 +
82 + fn oklch_to_rgb8(l: f32, c: f32, h: f32) -> (u8, u8, u8) {
83 + let oklch = Oklch::new(l, c, h);
84 + let srgb: Srgb = Srgb::from_color(oklch);
85 + let (r, g, b) = srgb.into_format::<u8>().into_components();
86 + (r, g, b)
87 + }
88 +
89 + fn emit_ramp(out: &mut String, name: &str, hue: f32, chroma: f32, stops: &std::collections::BTreeMap<String, f32>) {
90 + writeln!(out, " pub mod {name} {{").unwrap();
91 + writeln!(out, " use egui::Color32;").unwrap();
92 + for (stop, l) in stops {
93 + let (r, g, b) = oklch_to_rgb8(*l, chroma, hue);
94 + let upper = stop.to_uppercase();
95 + writeln!(
96 + out,
97 + " pub const {upper}: Color32 = Color32::from_rgb({r}, {g}, {b}); // L={l}",
98 + )
99 + .unwrap();
100 + }
101 + writeln!(out, " }}").unwrap();
102 + }
103 +
104 + fn main() {
105 + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
106 + let toml_path = manifest_dir.join("tokens.toml");
107 + let out_dir = manifest_dir.join("src/generated");
108 + let out_path = out_dir.join("tokens.rs");
109 +
110 + println!("cargo:rerun-if-changed={}", toml_path.display());
111 + println!("cargo:rerun-if-changed=build.rs");
112 +
113 + let text = fs::read_to_string(&toml_path).expect("read tokens.toml");
114 + let tokens: Tokens = toml::from_str(&text).expect("parse tokens.toml");
115 +
116 + let mut out = String::new();
117 + out.push_str("// Generated by build.rs from tokens.toml. Do not edit by hand.\n");
118 + out.push_str("// Edit tokens.toml (or docs/TOKENS.md upstream of it) instead.\n\n");
119 + out.push_str("#![allow(dead_code)]\n\n");
120 +
121 + // Color ramps.
122 + out.push_str("pub mod colors {\n");
123 + emit_ramp(&mut out, "light", tokens.tint.hue, tokens.tint.chroma, &tokens.ramp.light);
124 + emit_ramp(&mut out, "dark", tokens.tint.hue, tokens.tint.chroma, &tokens.ramp.dark);
125 +
126 + writeln!(out, " pub mod accents {{").unwrap();
127 + writeln!(out, " use egui::Color32;").unwrap();
128 + for (name, v) in &tokens.accents {
129 + let (r, g, b) = oklch_to_rgb8(v.l, v.c, v.h);
130 + let upper = name.to_uppercase();
131 + writeln!(
132 + out,
133 + " pub const {upper}: Color32 = Color32::from_rgb({r}, {g}, {b}); // oklch({l}% {c} {h})",
134 + l = v.l * 100.0, c = v.c, h = v.h,
135 + )
136 + .unwrap();
137 + }
138 + writeln!(out, " }}").unwrap();
139 + out.push_str("}\n\n");
140 +
141 + // Typography.
142 + let t = &tokens.typography;
143 + out.push_str("pub mod typography {\n");
144 + writeln!(out, " pub const FONT_MONO_WORKING: &str = {:?};", t.font_mono_working).unwrap();
145 + writeln!(out, " pub const FONT_MONO_WORKING_NOLIGATURES: &str = {:?};", t.font_mono_working_noligatures).unwrap();
146 + writeln!(out, " pub const FONT_MONO_DISPLAY: &str = {:?};", t.font_mono_display).unwrap();
147 + writeln!(out, " pub const SIZE_CAPTION: f32 = {};", as_float(t.size_caption)).unwrap();
148 + writeln!(out, " pub const SIZE_BODY: f32 = {};", as_float(t.size_body)).unwrap();
149 + writeln!(out, " pub const SIZE_SECTION_HEADER: f32 = {};", as_float(t.size_section_header)).unwrap();
150 + writeln!(out, " pub const SIZE_TITLE: f32 = {};", as_float(t.size_title)).unwrap();
151 + writeln!(out, " pub const SIZE_READOUT_SM: f32 = {};", as_float(t.size_readout_sm)).unwrap();
152 + writeln!(out, " pub const SIZE_READOUT_MD: f32 = {};", as_float(t.size_readout_md)).unwrap();
153 + writeln!(out, " pub const SIZE_READOUT_LG: f32 = {};", as_float(t.size_readout_lg)).unwrap();
154 + writeln!(out, " pub const SIZE_READOUT_XL: f32 = {};", as_float(t.size_readout_xl)).unwrap();
155 + writeln!(out, " pub const WEIGHT_BODY: u32 = {};", t.weight_body).unwrap();
156 + writeln!(out, " pub const WEIGHT_EMPHASIS: u32 = {};", t.weight_emphasis).unwrap();
157 + writeln!(out, " pub const TRACKING_BODY: f32 = {};", as_float(t.tracking_body)).unwrap();
158 + writeln!(out, " pub const TRACKING_DISPLAY: f32 = {};", as_float(t.tracking_display)).unwrap();
159 + writeln!(out, " pub const TRACKING_DISPLAY_CAPS: f32 = {};", as_float(t.tracking_display_caps)).unwrap();
160 + writeln!(out, " pub const LINE_HEIGHT_BODY: f32 = {};", as_float(t.line_height_body)).unwrap();
161 + writeln!(out, " pub const LINE_HEIGHT_TABULAR: f32 = {};", as_float(t.line_height_tabular)).unwrap();
162 + writeln!(out, " pub const LINE_HEIGHT_DISPLAY: f32 = {};", as_float(t.line_height_display)).unwrap();
163 + out.push_str("}\n\n");
164 +
165 + // Geometry.
166 + let g = &tokens.geometry;
167 + out.push_str("pub mod geometry {\n");
168 + writeln!(out, " pub const SPACE_XS: f32 = {};", as_float(g.space_xs)).unwrap();
169 + writeln!(out, " pub const SPACE_SM: f32 = {};", as_float(g.space_sm)).unwrap();
170 + writeln!(out, " pub const SPACE_MD: f32 = {};", as_float(g.space_md)).unwrap();
171 + writeln!(out, " pub const SPACE_LG: f32 = {};", as_float(g.space_lg)).unwrap();
172 + writeln!(out, " pub const SPACE_XL: f32 = {};", as_float(g.space_xl)).unwrap();
173 + writeln!(out, " pub const RADIUS_CONTROL: f32 = {};", as_float(g.radius_control)).unwrap();
174 + writeln!(out, " pub const RADIUS_OVERLAY: f32 = {};", as_float(g.radius_overlay)).unwrap();
175 + writeln!(out, " pub const RADIUS_DATA: f32 = {};", as_float(g.radius_data)).unwrap();
176 + writeln!(out, " pub const BORDER_WIDTH_DEFAULT: f32 = {};", as_float(g.border_width_default)).unwrap();
177 + writeln!(out, " pub const BORDER_WIDTH_FOCUS: f32 = {};", as_float(g.border_width_focus)).unwrap();
178 + writeln!(out, " pub const FOCUS_RING_OFFSET: f32 = {};", as_float(g.focus_ring_offset)).unwrap();
179 + out.push_str("}\n");
180 +
181 + fs::create_dir_all(&out_dir).expect("create generated dir");
182 + fs::write(&out_path, out).expect("write tokens.rs");
183 + }
184 +
185 + fn as_float(v: f32) -> String {
186 + if v.fract() == 0.0 {
187 + format!("{v:.1}")
188 + } else {
189 + format!("{v}")
190 + }
191 + }
@@ -1,0 +1,113 @@
1 + //! Renders the Alloy ramp, accent palette, and a handful of widgets so the
2 + //! tokens can be verified visually. Run with `cargo run -p alloy_ui --example showcase`.
3 +
4 + use alloy_ui::{
5 + Mode, Severity, SeverityTag, apply_alloy_visuals, colors, field_row, geometry, primary_button,
6 + section, typography,
7 + };
8 + use eframe::egui;
9 +
10 + struct Showcase {
11 + mode: Mode,
12 + ssid: String,
13 + password: String,
14 + }
15 +
16 + impl Default for Showcase {
17 + fn default() -> Self {
18 + Self {
19 + mode: Mode::Light,
20 + ssid: String::new(),
21 + password: String::new(),
22 + }
23 + }
24 + }
25 +
26 + impl eframe::App for Showcase {
27 + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
28 + apply_alloy_visuals(ctx, self.mode);
29 + }
30 +
31 + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
32 + ui.horizontal(|ui| {
33 + ui.label(egui::RichText::new("ALLOY SHOWCASE").size(typography::SIZE_TITLE));
34 + if ui.button(format!("Mode: {:?}", self.mode)).clicked() {
35 + self.mode = match self.mode {
36 + Mode::Light => Mode::Dark,
37 + Mode::Dark => Mode::Light,
38 + };
39 + }
40 + });
41 +
42 + section(ui, "Chrome ramp", |ui| {
43 + ui.horizontal_wrapped(|ui| {
44 + let stops: [(&str, egui::Color32); 10] = match self.mode {
45 + Mode::Light => [
46 + ("surface-sunken", colors::light::SURFACE_SUNKEN),
47 + ("surface", colors::light::SURFACE),
48 + ("surface-raised", colors::light::SURFACE_RAISED),
49 + ("surface-overlay", colors::light::SURFACE_OVERLAY),
50 + ("border-subtle", colors::light::BORDER_SUBTLE),
51 + ("border", colors::light::BORDER),
52 + ("border-strong", colors::light::BORDER_STRONG),
53 + ("text-muted", colors::light::TEXT_MUTED),
54 + ("text-secondary", colors::light::TEXT_SECONDARY),
55 + ("text-primary", colors::light::TEXT_PRIMARY),
56 + ],
57 + Mode::Dark => [
58 + ("surface-sunken", colors::dark::SURFACE_SUNKEN),
59 + ("surface", colors::dark::SURFACE),
60 + ("surface-raised", colors::dark::SURFACE_RAISED),
61 + ("surface-overlay", colors::dark::SURFACE_OVERLAY),
62 + ("border-subtle", colors::dark::BORDER_SUBTLE),
63 + ("border", colors::dark::BORDER),
64 + ("border-strong", colors::dark::BORDER_STRONG),
65 + ("text-muted", colors::dark::TEXT_MUTED),
66 + ("text-secondary", colors::dark::TEXT_SECONDARY),
67 + ("text-primary", colors::dark::TEXT_PRIMARY),
68 + ],
69 + };
70 + for (name, color) in stops {
71 + ui.vertical(|ui| {
72 + let (rect, _) =
73 + ui.allocate_exact_size(egui::vec2(72.0, 48.0), egui::Sense::hover());
74 + ui.painter().rect_filled(rect, geometry::RADIUS_DATA, color);
75 + ui.label(egui::RichText::new(name).size(typography::SIZE_CAPTION));
76 + });
77 + }
78 + });
79 + });
80 +
81 + section(ui, "Information palette", |ui| {
82 + ui.horizontal(|ui| {
83 + ui.add(SeverityTag::new(Severity::Info));
84 + ui.add(SeverityTag::new(Severity::Healthy));
85 + ui.add(SeverityTag::new(Severity::Warn));
86 + ui.add(SeverityTag::new(Severity::Error));
87 + });
88 + });
89 +
90 + section(ui, "Network", |ui| {
91 + field_row(ui, "SSID", |ui| {
92 + ui.text_edit_singleline(&mut self.ssid);
93 + });
94 + field_row(ui, "Password", |ui| {
95 + ui.add(egui::TextEdit::singleline(&mut self.password).password(true));
96 + });
97 + ui.add_space(geometry::SPACE_SM);
98 + let _ = primary_button(ui, "Connect");
99 + });
100 + }
101 + }
102 +
103 + fn main() -> eframe::Result<()> {
104 + let options = eframe::NativeOptions {
105 + viewport: egui::ViewportBuilder::default().with_inner_size([900.0, 700.0]),
106 + ..Default::default()
107 + };
108 + eframe::run_native(
109 + "Alloy showcase",
110 + options,
111 + Box::new(|_cc| Ok(Box::<Showcase>::default())),
112 + )
113 + }
@@ -1,0 +1,19 @@
1 + //! Alloy design system for egui.
2 + //!
3 + //! Token constants (color ramps, typography scale, geometry) are generated at
4 + //! build time from `tokens.toml`. See `docs/TOKENS.md` for the canonical
5 + //! decisions and `docs/COMPONENT-LIBRARY.md` for the four authoring idioms
6 + //! this crate exposes (functions, builders, HOFs, `impl Widget`).
7 + //!
8 + //! Per [DESIGN-LANGUAGE.md]: chrome is tinted-greyscale, color is reserved for
9 + //! information. Accents never appear on chrome.
10 +
11 + mod generated {
12 + pub mod tokens;
13 + }
14 + pub mod theme;
15 + pub mod widgets;
16 +
17 + pub use generated::tokens::{colors, geometry, typography};
18 + pub use theme::{Mode, apply_alloy_visuals};
19 + pub use widgets::*;
@@ -1,0 +1,130 @@
1 + //! Per-frame theme application.
2 + //!
3 + //! Apps call `apply_alloy_visuals` once per frame at the top of `update`. There
4 + //! is no other path by which Alloy visuals enter an app; this is enforced by
5 + //! convention, not by the type system, so the discipline matters.
6 +
7 + use egui::{Color32, CornerRadius, Stroke, Visuals, style::WidgetVisuals};
8 +
9 + use crate::generated::tokens::{colors, geometry};
10 +
11 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
12 + pub enum Mode {
13 + Light,
14 + Dark,
15 + }
16 +
17 + pub fn apply_alloy_visuals(ctx: &egui::Context, mode: Mode) {
18 + let mut style = (*ctx.global_style()).clone();
19 + style.visuals = match mode {
20 + Mode::Light => visuals_light(),
21 + Mode::Dark => visuals_dark(),
22 + };
23 + style.spacing.item_spacing = egui::vec2(geometry::SPACE_MD, geometry::SPACE_SM);
24 + style.spacing.button_padding = egui::vec2(geometry::SPACE_MD, geometry::SPACE_SM);
25 + style.spacing.window_margin = egui::Margin::same(geometry::SPACE_LG as i8);
26 + ctx.set_global_style(style);
27 + }
28 +
29 + fn visuals_light() -> Visuals {
30 + build_visuals(Palette {
31 + surface_sunken: colors::light::SURFACE_SUNKEN,
32 + surface: colors::light::SURFACE,
33 + surface_raised: colors::light::SURFACE_RAISED,
34 + surface_overlay: colors::light::SURFACE_OVERLAY,
35 + border_subtle: colors::light::BORDER_SUBTLE,
36 + border: colors::light::BORDER,
37 + border_strong: colors::light::BORDER_STRONG,
38 + text_muted: colors::light::TEXT_MUTED,
39 + text_secondary: colors::light::TEXT_SECONDARY,
40 + text_primary: colors::light::TEXT_PRIMARY,
41 + dark: false,
42 + })
43 + }
44 +
45 + fn visuals_dark() -> Visuals {
46 + build_visuals(Palette {
47 + surface_sunken: colors::dark::SURFACE_SUNKEN,
48 + surface: colors::dark::SURFACE,
49 + surface_raised: colors::dark::SURFACE_RAISED,
50 + surface_overlay: colors::dark::SURFACE_OVERLAY,
51 + border_subtle: colors::dark::BORDER_SUBTLE,
52 + border: colors::dark::BORDER,
53 + border_strong: colors::dark::BORDER_STRONG,
54 + text_muted: colors::dark::TEXT_MUTED,
55 + text_secondary: colors::dark::TEXT_SECONDARY,
56 + text_primary: colors::dark::TEXT_PRIMARY,
57 + dark: true,
58 + })
59 + }
60 +
61 + struct Palette {
62 + surface_sunken: Color32,
63 + surface: Color32,
64 + surface_raised: Color32,
65 + surface_overlay: Color32,
66 + border_subtle: Color32,
67 + border: Color32,
68 + border_strong: Color32,
69 + text_muted: Color32,
70 + text_secondary: Color32,
71 + text_primary: Color32,
72 + dark: bool,
73 + }
74 +
75 + fn build_visuals(p: Palette) -> Visuals {
76 + let base = if p.dark { Visuals::dark() } else { Visuals::light() };
77 +
78 + let radius = CornerRadius::same(geometry::RADIUS_CONTROL as u8);
79 + let border = Stroke::new(geometry::BORDER_WIDTH_DEFAULT, p.border);
80 + let border_subtle = Stroke::new(geometry::BORDER_WIDTH_DEFAULT, p.border_subtle);
81 + let focus = Stroke::new(geometry::BORDER_WIDTH_FOCUS, p.border_strong);
82 +
83 + let widget = |bg: Color32, fg: Color32, stroke: Stroke| WidgetVisuals {
84 + bg_fill: bg,
85 + weak_bg_fill: bg,
86 + bg_stroke: stroke,
87 + fg_stroke: Stroke::new(geometry::BORDER_WIDTH_DEFAULT, fg),
88 + corner_radius: radius,
89 + expansion: 0.0,
90 + };
91 +
92 + let mut v = base;
93 + v.window_fill = p.surface;
94 + v.panel_fill = p.surface;
95 + v.faint_bg_color = p.surface_sunken;
96 + v.extreme_bg_color = p.surface_sunken;
97 + v.code_bg_color = p.surface_sunken;
98 +
99 + v.window_stroke = border;
100 + v.window_corner_radius = CornerRadius::same(geometry::RADIUS_OVERLAY as u8);
101 +
102 + v.widgets.noninteractive = widget(p.surface_raised, p.text_primary, border_subtle);
103 + v.widgets.inactive = widget(p.surface_raised, p.text_primary, border);
104 + v.widgets.hovered = widget(p.surface_overlay, p.text_primary, border);
105 + v.widgets.active = widget(p.surface, p.text_primary, border);
106 + v.widgets.open = widget(p.surface_overlay, p.text_primary, border);
107 +
108 + v.selection.bg_fill = p.border_strong;
109 + v.selection.stroke = Stroke::new(geometry::BORDER_WIDTH_DEFAULT, p.text_primary);
110 +
111 + v.hyperlink_color = p.text_primary;
112 + v.warn_fg_color = p.text_secondary;
113 + v.error_fg_color = p.text_primary;
114 +
115 + // Focus is chrome, not data — borrow border_strong, never an accent.
116 + v.widgets.hovered.fg_stroke = Stroke::new(geometry::BORDER_WIDTH_DEFAULT, p.text_primary);
117 + v.window_highlight_topmost = false;
118 + v.override_text_color = Some(p.text_primary);
119 +
120 + // The egui struct doesn't expose a typed "focus ring" — focus is currently
121 + // implied by hover state. The themed widgets in `widgets.rs` paint an
122 + // explicit focus ring using `focus`/`FOCUS_RING_OFFSET` per the design
123 + // language. The `_ = focus` line keeps the binding load-bearing if visuals
124 + // grow a slot for it later.
125 + let _ = focus;
126 + let _ = p.text_secondary;
127 + let _ = p.text_muted;
128 +
129 + v
130 + }
@@ -1,0 +1,139 @@
1 + //! Themed primitives and layout combinators.
2 + //!
3 + //! See `docs/COMPONENT-LIBRARY.md` for the four idioms (functions, builders,
4 + //! HOFs, `impl Widget`). This module starts with the smallest useful set so
5 + //! marquee apps have something to build against; primitives land here, not in
6 + //! app crates.
7 +
8 + use egui::{Response, RichText, Stroke, Ui, Widget};
9 +
10 + use crate::generated::tokens::{colors, geometry, typography};
11 +
12 + /// One-shot primary button. Themed against `surface-raised` with a default
13 + /// border; egui handles hover/active state through the visuals applied in
14 + /// `theme::apply_alloy_visuals`.
15 + pub fn primary_button(ui: &mut Ui, label: &str) -> Response {
16 + ui.add(
17 + egui::Button::new(
18 + RichText::new(label)
19 + .size(typography::SIZE_BODY)
20 + .color(theme_text(ui)),
21 + )
22 + .corner_radius(geometry::RADIUS_CONTROL),
23 + )
24 + }
25 +
26 + /// Section header + content block. The `title` renders at `size-section-header`
27 + /// in the working font; spacing follows the geometry tokens.
28 + pub fn section<R>(ui: &mut Ui, title: &str, content: impl FnOnce(&mut Ui) -> R) -> R {
29 + ui.add_space(geometry::SPACE_LG);
30 + ui.label(
31 + RichText::new(title)
32 + .size(typography::SIZE_SECTION_HEADER)
33 + .strong()
34 + .color(theme_text(ui)),
35 + );
36 + ui.add_space(geometry::SPACE_SM);
37 + let result = ui.scope(content).inner;
38 + ui.add_space(geometry::SPACE_MD);
39 + result
40 + }
41 +
42 + /// Form-row layout: label on the left, control area on the right.
43 + pub fn field_row<R>(ui: &mut Ui, label: &str, control: impl FnOnce(&mut Ui) -> R) -> R {
44 + ui.horizontal(|ui| {
45 + ui.add_sized(
46 + [120.0, 0.0],
47 + egui::Label::new(
48 + RichText::new(label)
49 + .size(typography::SIZE_BODY)
50 + .color(theme_text_secondary(ui)),
51 + ),
52 + );
53 + ui.scope(control).inner
54 + })
55 + .inner
56 + }
57 +
58 + /// Severity tag — categorical accent on text, never on chrome.
59 + #[derive(Debug, Clone, Copy)]
60 + pub enum Severity {
61 + Info,
62 + Healthy,
63 + Warn,
64 + Error,
65 + }
66 +
67 + impl Severity {
68 + fn color(self) -> egui::Color32 {
69 + match self {
70 + Severity::Info => colors::accents::INFO,
71 + Severity::Healthy => colors::accents::HEALTHY,
72 + Severity::Warn => colors::accents::WARN,
73 + Severity::Error => colors::accents::ERROR,
74 + }
75 + }
76 +
77 + fn label(self) -> &'static str {
78 + match self {
79 + Severity::Info => "INFO",
80 + Severity::Healthy => "OK",
81 + Severity::Warn => "WARN",
82 + Severity::Error => "ERROR",
83 + }
84 + }
85 + }
86 +
87 + pub struct SeverityTag {
88 + severity: Severity,
89 + }
90 +
91 + impl SeverityTag {
92 + pub fn new(severity: Severity) -> Self {
93 + Self { severity }
94 + }
95 + }
96 +
97 + impl Widget for SeverityTag {
98 + fn ui(self, ui: &mut Ui) -> Response {
99 + let color = self.severity.color();
100 + let text = RichText::new(self.severity.label())
101 + .size(typography::SIZE_CAPTION)
102 + .color(color);
103 + ui.add(egui::Label::new(text))
104 + }
105 + }
106 +
107 + fn theme_text(ui: &Ui) -> egui::Color32 {
108 + ui.visuals().widgets.inactive.fg_stroke.color
109 + }
110 +
111 + fn theme_text_secondary(ui: &Ui) -> egui::Color32 {
112 + // Secondary text isn't a first-class slot in egui visuals; pull from the
113 + // generated tokens directly. Mode picked from `dark_mode` so the secondary
114 + // value tracks the active palette.
115 + if ui.visuals().dark_mode {
116 + colors::dark::TEXT_SECONDARY
117 + } else {
118 + colors::light::TEXT_SECONDARY
119 + }
120 + }
121 +
122 + /// Paint a focus ring around the last response, per design language
123 + /// (2px `border-strong` ring offset 1px outside the control geometry).
124 + pub fn focus_ring(ui: &Ui, response: &Response) {
125 + if response.has_focus() {
126 + let rect = response.rect.expand(geometry::FOCUS_RING_OFFSET);
127 + let color = if ui.visuals().dark_mode {
128 + colors::dark::BORDER_STRONG
129 + } else {
130 + colors::light::BORDER_STRONG
131 + };
132 + ui.painter().rect_stroke(
133 + rect,
134 + geometry::RADIUS_CONTROL,
135 + Stroke::new(geometry::BORDER_WIDTH_FOCUS, color),
136 + egui::StrokeKind::Outside,
137 + );
138 + }
139 + }
@@ -1,0 +1,88 @@
1 + # Alloy design tokens.
2 + #
3 + # This file is the build-time input. `build.rs` parses it, converts OKLCH
4 + # colors to sRGB, and emits `src/generated/tokens.rs` with typed constants.
5 + #
6 + # Canonical rationale and the decisions behind these values live in
7 + # `docs/TOKENS.md`. Edit that first; transcribe the resolved numbers here.
8 + # Round-trip is one-way: TOKENS.md -> tokens.toml -> tokens.rs.
9 +
10 + [tint]
11 + # Global tint hue and chroma for all chrome (surfaces, borders, text).
12 + hue = 80.0
13 + chroma = 0.012
14 +
15 + # Chrome ramp lightness stops. Same hue/chroma; only L varies.
16 + # Light mode is canonical; dark is the elevation-preserving derivative.
17 + [ramp.light]
18 + surface_sunken = 0.92
19 + surface = 0.96
20 + surface_raised = 0.98
21 + surface_overlay = 0.995
22 + border_subtle = 0.88
23 + border = 0.80
24 + border_strong = 0.65
25 + text_muted = 0.55
26 + text_secondary = 0.38
27 + text_primary = 0.18
28 +
29 + [ramp.dark]
30 + surface_sunken = 0.08
31 + surface = 0.12
32 + surface_raised = 0.15
33 + surface_overlay = 0.19
34 + border_subtle = 0.22
35 + border = 0.30
36 + border_strong = 0.48
37 + text_muted = 0.60
38 + text_secondary = 0.78
39 + text_primary = 0.94
40 +
41 + # Information palette. Identical OKLCH in both modes by design — accents
42 + # are recognized by hue, and inverting them per mode would defeat that.
43 + [accents]
44 + error = { l = 0.55, c = 0.18, h = 25.0 }
45 + warn = { l = 0.70, c = 0.15, h = 85.0 }
46 + healthy = { l = 0.62, c = 0.16, h = 145.0 }
47 + info = { l = 0.60, c = 0.16, h = 240.0 }
48 + syntax = { l = 0.55, c = 0.18, h = 310.0 }
49 +
50 + [typography]
51 + font_mono_working = "IosevkaNerdFont"
52 + font_mono_working_noligatures = "IosevkaTermNerdFont"
53 + font_mono_display = "DepartureMonoNerdFontMono"
54 +
55 + size_caption = 12.0
56 + size_body = 14.0
57 + size_section_header = 16.0
58 + size_title = 20.0
59 + size_readout_sm = 16.0
60 + size_readout_md = 24.0
61 + size_readout_lg = 48.0
62 + size_readout_xl = 72.0
63 +
64 + weight_body = 400
65 + weight_emphasis = 600
66 +
67 + tracking_body = 0.0
68 + tracking_display = 0.04
69 + tracking_display_caps = 0.08
70 +
71 + line_height_body = 1.4
72 + line_height_tabular = 1.3
73 + line_height_display = 1.0
74 +
75 + [geometry]
76 + space_xs = 2.0
77 + space_sm = 4.0
78 + space_md = 8.0
79 + space_lg = 16.0
80 + space_xl = 24.0
81 +
82 + radius_control = 4.0
83 + radius_overlay = 6.0
84 + radius_data = 0.0
85 +
86 + border_width_default = 1.0
87 + border_width_focus = 2.0
88 + focus_ring_offset = 1.0