Skip to main content

max / alloy

Remove presets from the console and from schema-DSL v1 apply_preset had no caller but its tests, and the machinery under it - the Preset type, the [[preset]] parsing and its cross-checking against declared fields - was dead with it. Presets are not on the Bind trait, so reaching one needs a mode of its own, and no shipped schema declares any: the mode would guard a feature nothing uses. A schema declaring [[preset]] is now an unknown key and takes the text-edit fallback. Reversible: the code is in git and the reasoning is in GoingsOn alloy 60a088c9.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 17:51 UTC
Commit: c0c919d977a9e117959e91ccc66ce285ec35fa40
Parent: 0b41fcc
4 files changed, +21 insertions, -222 deletions
M docs/CONSOLE.md +6 -7
@@ -286,7 +286,7 @@
286 286
287 287 ### View architecture
288 288
289 - Settled with the crate/binary split the rest of the console already follows (see [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md)): `alloy_tui` renders dumb themed field widgets and form chrome, and the binary owns the schema parser, the `toml_edit` document, validation, presets, and save. The pieces:
289 + Settled with the crate/binary split the rest of the console already follows (see [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md)): `alloy_tui` renders dumb themed field widgets and form chrome, and the binary owns the schema parser, the `toml_edit` document, validation, and save. The pieces:
290 290
291 291 - **The two tabs meet at one seam, `Bind`.** Everything above it is shared: the rows, the chrome, the modal edit, the validation. Below it, a tab differs in three things only. Where a value is read from (a `DocumentMut`, or a `timedatectl show` style key=value front). What committing one does. And where the field list comes from: parsed from a schema file on the Applications side, a hand-written Rust table on the System side, because there are a dozen System rows fronting different CLIs and ten schemas and growing. Neither side knows the other exists.
292 292 - **Committing returns effects rather than performing them**, which is the pattern every backend in the console already follows and is what keeps both binds testable on a machine with none of the tools installed. It also settles what dirty means per tab without either tab being told which kind it is: a file bind holds its edits in the document and emits one `Effect::Write` at save, and a command front emits its setter at commit and has nothing to save. The form asks the bind whether anything is pending and shows the save affordance accordingly. So Ctrl-S is a file thing, because a half-edited TOML is not valid TOML, and live state has no such constraint: `timedatectl set-timezone` is atomic on its own.
@@ -294,7 +294,6 @@
294 294 - **Editing is modal per field.** `mode: Navigate | Editing { row, buffer, original }`. In Navigate the reserved keymap holds (Tab / j-k move, Enter activates, Space folds a section or flips a bool, Ctrl-S saves). Enter on a string, number, or color field opens Editing, where the field owns every key. This is the case the classifier already documents: a view holding an active text input must not treat `q`, `/`, or `:` as reserved. Esc discards the buffer, Enter validates and commits; an invalid value stays in edit with its diagnostic shown rather than being written. Enums do not free-type: Enter opens a pick overlay (the `AlloyModal` + `AlloyList` idiom) listing each value's label and description and committing the raw value. Bools toggle in place.
295 295 - **The pick overlay filters as you type.** It was first specced against enums the size of rio's `cursor.shape`, three values. The System tab's zone row is an enum over `timedatectl list-timezones`, which is about 600 entries, and locales are worse. So the overlay owns a `TextField` and matches on a plain substring. No fuzzy match in v1: zone names are already terse and hierarchical, and a ranker over 600 strings is a scoring function to tune for no gain a substring does not give. Typing `den` and pressing enter is also fully offline, needs no database and no license, and cannot be confused by a VPN, which is what retired the idea of geolocating the installer's timezone.
296 296 - **Sections are collapsible.** One pane per schema `[[section]]`, folded with Space on its header, so the 29-slot colors group defaults to a single collapsed row rather than dominating the form. The binary flattens the visible rows each frame (section headers plus the fields of open sections); the `Cursor` rides that.
297 - - **Presets apply atomically.** A `[[preset]]` writes its whole `values` map into the document in one pass: one dirty increment, one undo entry. Undo is single-level in v1. Applying logs as `apply preset "..."`.
298 297 - **Save and quit.** Ctrl-S serializes the document back to the target path, logged as `write <path>` (the same `Effect::Write` shape the `alloy pkg` export wrapper established). Quitting with unsaved edits confirms through `AlloyModal`, reusing the Cancel-that-is-not-Quit machinery `alloy pkg` forced into the shell.
299 298 - **List-of-tables is read-only in v1.** `type = "list"` records (rio's `bindings.keys`) render through a display-only `AlloyTable`; add, remove, and cell-edit route to the text-edit fallback. Cheap to defer because rio ships bindings empty (Sway owns the global binds). Full table editing is v1.1.
300 299 - **Fallback.** A file the form engine cannot render opens in the syntax-highlighted text-edit pane instead, and a diagnostic heads the pane when there is one to show. Two routes into it are built: a schema declaring a syntax with no bind behind it (`sway`, `kdl`, `text`), and a target the `toml_edit` bind refused to parse. The second is the one that changed a behavior rather than adding one — a config that failed to parse used to render as a red paragraph, so the one thing a user could not do with a broken config was open it and fix it.
@@ -313,7 +312,7 @@
313 312
314 313 Alloy authors a small TOML dialect for describing TOML (and a parallel one for KDL). Not JSON Schema, which is powerful but verbose and awkward for humans. Not TOML's own type system, which is insufficient (no enums, no docs, no cross-field constraints).
315 314
316 - Schema-format v1, worked in full at [`crates/alloy/testdata/rio.toml.schema`](../crates/alloy/testdata/rio.toml.schema). The design pass against rio's real config surface shook out five affordances beyond a naive field list: sections, groups, presets, format hints, and an unknown-key policy.
315 + Schema-format v1, worked in full at [`crates/alloy/testdata/rio.toml.schema`](../crates/alloy/testdata/rio.toml.schema). The design pass against rio's real config surface shook out five affordances beyond a naive field list: sections, groups, presets, format hints, and an unknown-key policy. Presets did not survive the build; see below.
317 316
318 317 That worked example is a test fixture now rather than a shipped schema, since shop replaced rio in the image (see [STACK.md](STACK.md#terminal)). It stays the format's reference because nothing shipped exercises the DSL as widely: `schemas/shop.toml.schema`, the only catalog entry today, is three plain fields, which is what a terminal needs when it resolves its palette from a theme id instead of from ninety transcribed keys.
319 318
@@ -335,7 +334,7 @@
335 334
336 335 **Groups** (`[[group]]`) collapse repetition. Rio's `[colors]` has 29 palette slots, all colors, all hex. A `[[group]]` with `path = "colors"`, `type = "color"`, `format = "hex"`, and 29 `entries` expands to 29 fields at `colors.<key>` without 29 near-identical `[[field]]` blocks. Groups are DSL sugar over fields, not a runtime concept: the editor materializes them into the same form widgets.
337 336
338 - **Presets** (`[[preset]]`) give palette-heavy configs the UX they need: one action swaps a bundle. Each preset declares `name`, `description`, and a `values` map of `path` to value. Applied as one atomic edit: single dirty state, one undo entry. Presets are the answer to "how does a user pick a theme without editing 29 hex codes." Optional per schema.
337 + **Presets are gone, ruled 2026-08-27** (GoingsOn alloy `60a088c9`). `[[preset]]` was specced here as the answer to "how does a user pick a theme without editing 29 hex codes", and the parser and the atomic-apply path were both built and tested. Nothing ever reached them: presets are not on the `Bind` trait, so exposing one needs a mode of its own rather than a keybinding, and no schema the image ships declares any. Rather than keep a tested capability with no caller, the whole construct came out of the DSL. A schema declaring `[[preset]]` is now an unknown key and takes the fallback. The code is in git and the reasoning is in `60a088c9`, so if a schema starts wanting bundles the mode earns itself then.
339 338
340 339 **List-of-tables** (`type = "list"`, `element = {type = "table", fields = [...]}`) covers repeating records like `[[bindings.keys]]`. The element sub-schema is inlined for authoring simplicity, with no cross-file references to chase.
341 340
@@ -343,7 +342,7 @@
343 342
344 343 Properties the format has to hold:
345 344
346 - - **Readable and writable by hand.** Alloy will author dozens of these. Rio's schema at ~250 lines is the size baseline; if a schema takes a day to write, the catalog is months of work. If it takes an hour, it's a weekend project. Groups and presets are the sugar that keep the hour number honest.
345 + - **Readable and writable by hand.** Alloy will author dozens of these. Rio's schema at ~250 lines is the size baseline; if a schema takes a day to write, the catalog is months of work. If it takes an hour, it's a weekend project. Groups are the sugar that keeps the hour number honest.
347 346 - **Extensible without breaking editors.** New field types and constraints will get added over time. The editor treats unknown types as text-edit fallback rather than refusing to open the file; `schema_version` on the header lets the editor detect an incompatible schema DSL and route to fallback cleanly.
348 347
349 348 The KDL parallel dialect follows the same shape; only the `path` grammar and the roundtrip-safe editing library differ (see the KDL open question below).
@@ -382,7 +381,7 @@
382 381 The ordering below is the reverse of what this document originally planned, which put `alloy config` alone at v0.5 and every live-state subcommand at v1. The live-state views went first instead. They are small enough to carve one at a time, and each one forced a piece of shared machinery into existence against something real: the shell chrome and log pane from `net`, the second list and the `Cursor` from `audio`, the two-pane layout and `AlloyConnector` from `audio`'s routing, the background tick from watching streams appear. `alloy settings` needs the form widgets and the schema parser at once, and it is a better shape to build on a shell that has already carried three screens.
383 382
384 383 - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, `alloy setup`, and all three `alloy pkg` tabs (`box`, plus `install` and `update` fronting `rpm-ostree status --json`). Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program.
385 - - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback, and `schemas/sway.schema` is the header that declares it). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only.
384 + - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback, and `schemas/sway.schema` is the header that declares it). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) live diagnostics, the collapsible sections having landed with the tab, presets having been removed from the DSL; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only.
386 385 - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
387 386 - **Shipped, and the only screen that is not a question.** `alloy install`'s credits page, added after the summary as a sixth step. It names each project Alloy ships, its SPDX identifier and its URL, off `crates/alloy/credits.toml` embedded in the binary. The install now starts from this screen rather than from the summary, which is the ordering the page argues for: the last screen before a disk is repartitioned should be the one that says whose work is about to be written to it. Curated rather than generated, so it drifts by design and keeping it current is a release step (see [IMAGE.md](IMAGE.md)). Not a source offer and not a license-text viewer: the full texts stay in `/usr/share/licenses` on the installed system.
388 387 - **Shipped as a view, with its enforcement half still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. usbguard itself is in both profiles as of 2026-08-22 and its daemon is not enabled, which is the deliberate state rather than an oversight: the package's stock policy is an empty rule file plus `ImplicitPolicyTarget=block`, so arming it before the policy exists deauthorizes the keyboard at boot. The keyboard gate landed the same day and is the part that makes deny-unknown safe: `usr/bin/alloy-usb-gate` drops enforcement whenever the machine has zero usable keyboards, continuously rather than at boot, on the client profile only. It counts `ID_INPUT_KEYBOARD` and not `ID_INPUT_KEY`, which is what keeps a power button from reading as a keyboard, and it reads the input subsystem rather than the USB bus, which is what makes the Framework's i8042 keyboard count without a special case. What is left is the policy itself: deny-unknown, with an activation the user performs from this screen.
@@ -401,7 +400,7 @@
401 400
402 401 ## Open questions
403 402
404 - - [x] **Schema format finalized: schema-format v1.** Worked example at [`crates/alloy/testdata/rio.toml.schema`](../crates/alloy/testdata/rio.toml.schema); DSL reference in the section above. The rio design pass surfaced five affordances the original sketch missed (sections, groups, presets, format hints, unknown-key policy).
403 + - [x] **Schema format finalized: schema-format v1.** Worked example at [`crates/alloy/testdata/rio.toml.schema`](../crates/alloy/testdata/rio.toml.schema); DSL reference in the section above. The rio design pass surfaced five affordances the original sketch missed (sections, groups, presets, format hints, unknown-key policy); presets were removed again in 2026-08.
405 404 - [ ] KDL editing story: the `kdl` crate ecosystem in Rust is less mature than `toml_edit`. Verify roundtrip-safe editing is achievable before committing to the "one editor, both formats" pitch. If not, KDL configs get the text-edit fallback until it is.
406 405 - [x] **Command-log pane: always on.** The pedagogical claim ("teaches its own primitives") only lands if the log is visible. Users who dislike it can add a hide toggle later; the shipped default should teach.
407 406 - [x] **Naming: `alloy settings` is the entry point, `alloy config <path>` opens one file.** Still no `alloy edit` alias, and the reasoning that settled the second name holds: the "config = editor" mental model works for KDL too, since these are structured machine-editable formats. What changed in 2026-07 is that a path is the wrong thing to have to know first. Someone looking for the time or the hostname has no path in mind, and someone looking for rio's config wants rio, not `~/.config/rio/config.toml`. So the verb people use is `alloy settings` and `alloy config <path>` is kept rather than deprecated, as the direct-open form for scripts and sidecars. Same treatment `alloy update` got against `alloy pkg`.
@@ -114,8 +114,8 @@
114 114 schema: Schema,
115 115 path: PathBuf,
116 116 document: DocumentMut,
117 - /// Edits since the last save. A count and not a flag because a preset is
118 - /// one edit however many values it carries, and the count is what says so.
117 + /// Edits since the last save. A count and not a flag because undo is
118 + /// per-edit, and the count is what says how many steps back there are.
119 119 edits: usize,
120 120 }
121 121
@@ -202,34 +202,6 @@
202 202 }
203 203 }
204 204 }
205 -
206 - /// Apply a preset's whole values map as one edit.
207 - ///
208 - /// One increment, so quitting after applying one asks once and undoing it
209 - /// later is one step. The values were checked against their fields when the
210 - /// schema was parsed, so this cannot half-apply.
211 - pub(crate) fn apply_preset(&mut self, name: &str) -> Result<()> {
212 - let preset = self
213 - .schema
214 - .presets
215 - .iter()
216 - .find(|preset| preset.name == name)
217 - .with_context(|| format!("no preset named \"{name}\""))?;
218 -
219 - // Collected first: the write borrows the document mutably and the
220 - // preset borrows the schema, which the same `self` owns.
221 - let values: Vec<(String, Value)> = preset
222 - .values
223 - .iter()
224 - .map(|(path, value)| (path.clone(), value.clone()))
225 - .collect();
226 -
227 - for (path, value) in values {
228 - write(&mut self.document, &path, &value)?;
229 - }
230 - self.edits += 1;
231 - Ok(())
232 - }
233 205 }
234 206
235 207 impl Bind for FileBind {
@@ -450,10 +422,6 @@
450 422 { key = \"background\", default = \"#e4ded6\" },
451 423 { key = \"foreground\", default = \"#1a1816\" },
452 424 ]
453 -
454 - [[preset]]
455 - name = \"Akari Night\"
456 - values = { \"colors.background\" = \"#1a1816\", \"colors.foreground\" = \"#e4ded6\" }
457 425 ";
458 426
459 427 fn bind(text: &str) -> FileBind {
@@ -647,34 +615,6 @@
647 615 assert!(effects.is_empty(), "{effects:?}");
648 616 }
649 617
650 - #[test]
651 - fn a_preset_is_one_edit_however_many_values_it_carries() {
652 - let mut bind = bind("");
653 - bind.apply_preset("Akari Night").unwrap();
654 - assert_eq!(
655 - bind.read("colors.background"),
656 - Some(Value::String("#1a1816".into())),
657 - );
658 - assert_eq!(
659 - bind.read("colors.foreground"),
660 - Some(Value::String("#e4ded6".into())),
661 - );
662 -
663 - // Two values, one save-confirm, one undo step.
664 - let mut counted = bind;
665 - counted.save().unwrap();
666 - counted.apply_preset("Akari Night").unwrap();
667 - assert!(counted.dirty());
668 - assert_eq!(counted.edits, 1);
669 - }
670 -
671 - #[test]
672 - fn an_unknown_preset_is_an_error() {
673 - let mut bind = bind("");
674 - let error = error(bind.apply_preset("Akari Noon"));
675 - assert!(error.contains("Akari Noon"), "{error}");
676 - }
677 -
678 618 #[test]
679 619 fn keys_no_field_declares_are_reported_by_path() {
680 620 let bind = bind(
@@ -13,10 +13,10 @@
13 13 //! calls it "not a runtime concept". A group of 29 palette entries becomes 29
14 14 //! ordinary [`Field`]s at `colors.<key>`, so every consumer downstream sees
15 15 //! one flat list and no view has to know groups existed.
16 - //! - **Defaults and presets are checked against the fields they name.** These
17 - //! files are hand-written, dozens of them eventually, and a `default` outside
18 - //! its own `range` or a preset naming a path that no field declares is an
19 - //! authoring mistake that would otherwise surface as a wrong form at runtime.
16 + //! - **Defaults are checked against the fields they name.** These files are
17 + //! hand-written, dozens of them eventually, and a `default` outside its own
18 + //! `range` is an authoring mistake that would otherwise surface as a wrong
19 + //! form at runtime.
20 20 //!
21 21 //! Every error out of here is a fallback diagnostic: docs/CONSOLE.md routes an
22 22 //! unknown `schema_version`, an unknown field type, or an unreadable schema to
@@ -44,14 +44,13 @@
44 44 /// the constraint silently. Fallback is the honest outcome.
45 45 const DSL_VERSION: &str = "1";
46 46
47 - /// A parsed schema: the header, the UI sections in declared order, every field
48 - /// with groups already expanded, and the presets.
47 + /// A parsed schema: the header, the UI sections in declared order, and every
48 + /// field with groups already expanded.
49 49 #[derive(Debug)]
50 50 pub(crate) struct Schema {
51 51 pub(crate) header: Header,
52 52 pub(crate) sections: Vec<Section>,
53 53 pub(crate) fields: Vec<Field>,
54 - pub(crate) presets: Vec<Preset>,
55 54 }
56 55
57 56 /// The `[schema]` block.
@@ -283,16 +282,6 @@
283 282 pub(crate) description: Option<String>,
284 283 }
285 284
286 - /// A bundle of values applied in one action: one dirty increment, one undo entry.
287 - #[derive(Debug)]
288 - pub(crate) struct Preset {
289 - pub(crate) name: String,
290 - pub(crate) description: Option<String>,
291 - /// Field path to value, sorted by path. Every path here names a declared
292 - /// field; that is checked at parse time.
293 - pub(crate) values: Vec<(String, Value)>,
294 - }
295 -
296 285 impl Schema {
297 286 /// Read and parse a schema file.
298 287 pub(crate) fn load(path: &Path) -> Result<Self> {
@@ -356,10 +345,7 @@
356 345 // as text would read as the schema not having been found at all.
357 346 if !syntax.forms() {
358 347 ensure!(
359 - raw.section.is_empty()
360 - && raw.field.is_empty()
361 - && raw.group.is_empty()
362 - && raw.preset.is_empty(),
348 + raw.section.is_empty() && raw.field.is_empty() && raw.group.is_empty(),
363 349 "syntax is \"{}\", which opens the text-edit fallback, so this schema's \
364 350 fields and sections would never be rendered — declare the header alone",
365 351 syntax.name(),
@@ -424,17 +410,10 @@
424 410 }
425 411 }
426 412
427 - let presets = raw
428 - .preset
429 - .into_iter()
430 - .map(|preset| preset.normalize(&fields))
431 - .collect::<Result<Vec<_>>>()?;
432 -
433 413 Ok(Self {
434 414 header,
435 415 sections,
436 416 fields,
437 - presets,
438 417 })
439 418 }
440 419
@@ -501,8 +480,6 @@
501 480 field: Vec<RawField>,
502 481 #[serde(default)]
503 482 group: Vec<RawGroup>,
504 - #[serde(default)]
505 - preset: Vec<RawPreset>,
506 483 }
507 484
508 485 #[derive(Deserialize)]
@@ -568,14 +545,6 @@
568 545 description: Option<String>,
569 546 }
570 547
571 - #[derive(Deserialize)]
572 - #[serde(deny_unknown_fields)]
573 - struct RawPreset {
574 - name: String,
575 - description: Option<String>,
576 - values: toml::Table,
577 - }
578 -
579 548 impl RawField {
580 549 fn normalize(self) -> Result<Field> {
581 550 let kind = self.kind()?;
@@ -838,34 +807,9 @@
838 807 }
839 808 }
840 809
841 - impl RawPreset {
842 - fn normalize(self, fields: &[Field]) -> Result<Preset> {
843 - let mut values = Vec::with_capacity(self.values.len());
844 - for (path, value) in self.values {
845 - let field = fields
846 - .iter()
847 - .find(|field| field.path == path)
848 - .with_context(|| {
849 - format!(
850 - "preset \"{}\" sets `{path}`, which no field declares",
851 - self.name,
852 - )
853 - })?;
854 - check_value(&field.kind, &value)
855 - .with_context(|| format!("preset \"{}\" sets `{path}`", self.name))?;
856 - values.push((path, value));
857 - }
858 - Ok(Preset {
859 - name: self.name,
860 - description: self.description,
861 - values,
862 - })
863 - }
864 - }
865 -
866 810 /// Whether a value could be written to a field, by type and constraint.
867 811 ///
868 - /// Presets are checked with this at parse time; the same call is what an edit
812 + /// Defaults are checked with this at parse time; the same call is what an edit
869 813 /// is checked with before it is committed.
870 814 pub(crate) fn check_value(kind: &FieldKind, value: &Value) -> Result<()> {
871 815 match kind {
@@ -998,7 +942,7 @@
998 942 /// A fixture rather than a shipped schema since the terminal swap took rio
999 943 /// out of the image. Kept whole anyway: what it is worth here is the
1000 944 /// breadth, and trimming it to the parts a current schema uses would leave
1001 - /// groups, presets and list-of-tables covered by nothing.
945 + /// groups and list-of-tables covered by nothing.
1002 946 const RIO: &str = include_str!("../testdata/rio.toml.schema");
1003 947
1004 948 fn rio() -> Schema {
@@ -1035,7 +979,6 @@
1035 979 );
1036 980 assert_eq!(schema.header.unknown_keys, UnknownKeys::Preserve);
1037 981 assert_eq!(schema.sections.len(), 7);
1038 - assert_eq!(schema.presets.len(), 1);
1039 982 }
1040 983
1041 984 // 22 `[[field]]` blocks plus the 29 entries the colors group expands to.
@@ -1112,43 +1055,6 @@
1112 1055 assert!(!element[2].required, "mode is not");
1113 1056 }
1114 1057
1115 - #[test]
1116 - fn a_preset_names_only_declared_fields() {
1117 - let schema = rio();
1118 - let preset = &schema.presets[0];
1119 - assert_eq!(preset.values.len(), 29);
1120 - for (path, _) in &preset.values {
1121 - assert!(schema.field(path).is_some(), "{path} is declared");
1122 - }
1123 - }
1124 -
1125 - #[test]
1126 - fn a_preset_setting_an_undeclared_path_is_an_error() {
1127 - let error = error(parse(
1128 - "[[field]]\n\
1129 - path = \"colors.red\"\n\
1130 - type = \"color\"\n\
1131 - [[preset]]\n\
1132 - name = \"Dawn\"\n\
1133 - values = { \"colors.blue\" = \"#304050\" }\n",
1134 - ));
1135 - assert!(error.contains("colors.blue"), "{error}");
1136 - }
1137 -
1138 - #[test]
1139 - fn a_preset_value_is_checked_against_the_field_it_sets() {
1140 - let error = error(parse(
1141 - "[[field]]\n\
1142 - path = \"fonts.size\"\n\
1143 - type = \"float\"\n\
1144 - range = [6.0, 48.0]\n\
1145 - [[preset]]\n\
1146 - name = \"Huge\"\n\
1147 - values = { \"fonts.size\" = 96.0 }\n",
1148 - ));
1149 - assert!(error.contains("fonts.size"), "{error}");
1150 - }
1151 -
1152 1058 // Sections are matched on whole segments and longest-first, which is what
1153 1059 // keeps `colors.cursor` out of the `cursor` section.
1154 1060 #[test]
@@ -3,7 +3,7 @@
3 3 # rio was Alloy's terminal until 2026-07-31, when shop replaced it
4 4 # (docs/STACK.md#terminal). This file left `schemas/` with rio itself, and is
5 5 # kept here because it is the only artifact that exercises the whole schema
6 - # DSL at once: structured enums, a 29-entry group, a preset, and a
6 + # DSL at once: structured enums, a 29-entry group, and a
7 7 # list-of-tables. `schemas/shop.toml.schema` is three plain fields and would
8 8 # leave most of the parser untested.
9 9 #
@@ -51,7 +51,7 @@
51 51
52 52 [[section]]
53 53 path = "colors"
54 - description = "ANSI palette and semantic colors. Presets swap the whole palette in one action."
54 + description = "ANSI palette and semantic colors."
55 55
56 56 [[section]]
57 57 path = "bindings"
@@ -241,15 +241,14 @@
241 241 # Group — colors
242 242 #
243 243 # All 25 palette slots share type = color, format = hex. The group
244 - # expands to one field per entry at `colors.<key>`. Presets below
245 - # swap the whole palette in one action.
244 + # expands to one field per entry at `colors.<key>`.
246 245 # -------------------------------------------------------------------
247 246
248 247 [[group]]
249 248 path = "colors"
250 249 type = "color"
251 250 format = "hex"
252 - description = "Terminal palette. The Akari Dawn preset is the shipped default; individual slots can be tuned or another preset applied."
251 + description = "Terminal palette. Every slot carries the Akari Dawn default and can be tuned on its own."
253 252 entries = [
254 253 { key = "background", default = "#e4ded6", description = "Surface background." },
255 254 { key = "foreground", default = "#1a1816", description = "Primary text." },
@@ -287,51 +286,6 @@
287 286 { key = "light-foreground", default = "#1a1816", description = "Bright foreground." },
288 287 ]
289 288
290 - # -------------------------------------------------------------------
291 - # Presets — grouped-swap UX.
292 - #
293 - # Applying a preset writes every listed path in one atomic edit
294 - # (single dirty state, one undo entry). Alloy currently ships one:
295 - # Akari Dawn (Alloy's default light theme). A dark-mode counterpart
296 - # (Akari Night, see MNW/shared/themes/akari-night.toml) lands as a
297 - # second preset once the dark-mode ANSI mapping is worked in real use.
298 - # -------------------------------------------------------------------
299 -
300 - [[preset]]
301 - name = "Akari Dawn (light)"
302 - description = "Alloy's default light theme. Warm-clay paper in lantern light — based on Shu Kutsuzawa's Akari (MIT). Source: MNW/shared/themes/akari-dawn.toml."
303 - values = {
304 - "colors.background" = "#e4ded6",
305 - "colors.foreground" = "#1a1816",
306 - "colors.cursor" = "#8a4530",
307 - "colors.selection-background" = "#dad2c7",
308 - "colors.selection-foreground" = "#1a1816",
309 - "colors.black" = "#1a1816",
310 - "colors.red" = "#6a2828",
311 - "colors.green" = "#3a5830",
312 - "colors.yellow" = "#b07840",
313 - "colors.blue" = "#304050",
314 - "colors.magenta" = "#806080",
315 - "colors.cyan" = "#222d38",
316 - "colors.white" = "#ede7de",
317 - "colors.dim-black" = "#222d38",
318 - "colors.dim-red" = "#8a4530",
319 - "colors.dim-green" = "#3a5830",
320 - "colors.dim-yellow" = "#b07840",
321 - "colors.dim-blue" = "#304050",
322 - "colors.dim-magenta" = "#806080",
323 - "colors.dim-cyan" = "#514b45",
324 - "colors.dim-foreground" = "#514b45",
325 - "colors.light-black" = "#514b45",
326 - "colors.light-red" = "#8a4530",
327 - "colors.light-green" = "#3a5830",
328 - "colors.light-yellow" = "#b07840",
329 - "colors.light-blue" = "#304050",
330 - "colors.light-magenta" = "#806080",
331 - "colors.light-cyan" = "#222d38",
332 - "colors.light-foreground" = "#1a1816",
333 - }
334 -
335 289 # -------------------------------------------------------------------
336 290 # Fields — bindings (list of tables)
337 291 # -------------------------------------------------------------------