Skip to main content

max / alloy

Schema format v1: rio design pass The CONSOLE.md sketch was working but never had a real config underneath it. Working the rio config surface end-to-end shook out five affordances the sketch missed: - Sections. UI-level grouping (one collapsible pane per section); fields fall into a section by path prefix. Explicit rather than path-prefix-inferred so section descriptions and ordering land where they belong. - Groups. Repetition sugar. Rio's [colors] has 29 palette slots, all colors, all hex; one [[group]] with entries collapses 29 near- identical [[field]] blocks into one block. Groups materialize into the same form widgets at edit time; DSL sugar, not a runtime concept. - Presets. Palette-heavy configs need "apply Flatwhite" as a single action, not "edit 29 hex codes." [[preset]] declares name + description + values map; applied atomically as one dirty edit, one undo entry. - Format hints per type. color format = hex | hex-alpha | css; path format = file | dir | any + must_exist. Enum values may be flat or structured ({value, label, description}) when the raw value is unfriendly (rio's "Disabled" / "Transparent" get labels). - Unknown-key policy. Defaults to preserve so a rio release adding a field never bricks the editor — the field survives the round trip and surfaces as an info diagnostic. schema_version on the header lets the editor detect an incompatible DSL and route to text-edit fallback. Cross-field constraints deliberately excluded — JSON Schema tarpit; the editor renders every field, the target tool ignores irrelevant ones. enabled_when can land in DSL v2 if a case shows up. Deliverables: - schemas/rio.toml.schema: complete, covers every field in the shipped rio config (navigation, window, renderer, fonts.{regular, bold,italic,bold-italic}, cursor, colors group, bindings.keys list-of-tables, plus one Alloy Flatwhite preset). - docs/CONSOLE.md#the-schema-format: rewritten from sketch to spec. Types / constraints / format hints / meta reference; two required properties (hand-authorable, extensible) preserved. - Open question flipped to [x] with a pointer to the worked example. KDL parallel dialect follows the same shape; only path grammar and roundtrip library differ. KDL open question stays [ ] as separate verification work.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 15:23 UTC
Signed with PGP, not checked
Commit: 71d5103b53dddf2a782d6385a342902e18fc674d
Parent: f6cb27c
2 files changed, +362 insertions, -29 deletions
M docs/CONSOLE.md +25 -29
@@ -56,41 +56,37 @@
56 56
57 57 Alloy authors a small TOML dialect for describing TOML (and a parallel one for KDL). Not JSON Schema — JSON Schema is powerful but verbose and awkward for humans. Not TOML's own type system — insufficient (no enums, no docs, no cross-field constraints).
58 58
59 - Sketch:
59 + Schema-format v1, worked in full at [`schemas/rio.toml.schema`](../schemas/rio.toml.schema). The design pass against rio's real config surface shaked out five affordances beyond a naive field list: sections, groups, presets, format hints, and an unknown-key policy.
60 +
61 + **Header.** Every schema declares its target file, the tool it targets, a target-version semver range, the schema-DSL version, and an unknown-key policy. Unknown keys default to `preserve` so that a rio release adding a new field never bricks the editor — the field survives the edit round-trip and surfaces as an info diagnostic.
60 62
61 63 ```toml
62 - # schemas/rio.toml.schema
63 64 [schema]
64 - target = "rio.toml"
65 - version = "0.2"
66 -
67 - [[field]]
68 - path = "colors.background"
69 - type = "color"
70 - default = "#0a0a0a"
71 - description = "Terminal background color."
72 -
73 - [[field]]
74 - path = "cursor.shape"
75 - type = "enum"
76 - values = ["block", "underline", "beam"]
77 - default = "block"
78 - description = "Cursor shape."
79 -
80 - [[field]]
81 - path = "window.opacity"
82 - type = "float"
83 - range = [0.0, 1.0]
84 - default = 1.0
85 - description = "Window opacity (0 = transparent, 1 = opaque)."
65 + target = "rio.toml"
66 + target_tool = "rio"
67 + target_version = ">=0.2"
68 + schema_version = "1"
69 + unknown_keys = "preserve" # {preserve | error}; default preserve
86 70 ```
87 71
88 - Field types: `bool`, `int`, `float`, `string`, `color`, `path`, `enum`, `list<T>`, `table` (with nested fields). Constraints: `range`, `pattern`, `values`, `required`.
72 + **Sections** (`[[section]]`) declare UI-level grouping: one collapsible pane per section, ordered as declared. Fields fall into their section by path prefix. Without sections the form would be a flat list ordered by field-declaration; sections make headers and per-section descriptions explicit.
89 73
90 - Two properties this format has to hold:
74 + **Fields** (`[[field]]`) carry the atomic edit unit. Every field has `path` (dotted TOML path — hyphens are literal key names), `type`, `description`, and `default`. Types: `bool`, `int`, `float`, `string`, `color`, `path`, `enum`, `list`, `table`. Constraints: `range` (int/float), `pattern` (string regex), `values` (enum), `required` (default false). Format hints: `color format = "hex" | "hex-alpha" | "css"`, `path format = "file" | "dir" | "any"` + optional `must_exist`. `enum` values may be flat (`["a", "b"]`) or structured (`[{value, label, description}, ...]`) when the raw value is unfriendly (rio's `"Disabled"` / `"Transparent"` gets a friendlier label).
91 75
92 - - **Readable and writable by hand.** Alloy will author dozens of these. If they take an hour each, the schema catalog is a weekend project. If they take a day each, it's a months-shaped commitment.
93 - - **Extensible without breaking editors.** New field types get added over time. The editor treats unknown types as a text-edit fallback rather than refusing to open the file.
76 + **Groups** (`[[group]]`) collapse repetition. Rio's `[colors]` has ~25 palette slots, all colors, all hex — a `[[group]]` with `path = "colors"`, `type = "color"`, `format = "hex"`, and 25 `entries` expands to 25 fields at `colors.<key>` without 25 near-identical `[[field]]` blocks. Groups are DSL sugar over fields, not a runtime concept — the editor materializes them into the same form widgets.
77 +
78 + **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 → 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 25 hex codes." Optional per schema.
79 +
80 + **List-of-tables** (`type = "list"`, `element = {type = "table", fields = [...]}`) covers repeating records like `[[bindings.keys]]`. The element sub-schema is inlined for authoring simplicity — no cross-file references to chase.
81 +
82 + **Cross-field constraints deliberately excluded.** JSON Schema showed how much complexity `if`/`then`/`allOf`/`oneOf` buys. The editor renders every field; the target tool ignores the irrelevant ones. If a specific "hide field B when field A is off" case shows up in daily use, `enabled_when` can land in schema-DSL v2.
83 +
84 + Properties the format has to hold:
85 +
86 + - **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.
87 + - **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.
88 +
89 + 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).
94 90
95 91 ## `alloy_tui` — the ratatui design system
96 92
@@ -130,7 +126,7 @@
130 126
131 127 ## Open questions
132 128
133 - - [ ] Schema format finalization — the sketch above is a working proposal; needs a design pass with a real target config (probably rio) to shake out gaps.
129 + - [x] **Schema format finalized: schema-format v1.** Worked example at [`schemas/rio.toml.schema`](../schemas/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).
134 130 - [ ] KDL editing story — `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.
135 131 - [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.
136 132 - [x] **Naming: one entry point — `alloy config <path>`.** No `alloy edit` alias. Fewer names to remember; the "config = editor" mental model holds for KDL files too — they're structured, machine-editable formats, which is what `alloy config` addresses.
@@ -1,0 +1,337 @@
1 + # Alloy schema for rio.toml
2 + #
3 + # Target file: ~/.config/rio/config.toml
4 + # Alloy ships defaults at etc/skel/.config/rio/config.toml.
5 + #
6 + # Format: schema-format v1. See docs/CONSOLE.md#the-schema-format for
7 + # the DSL reference. `alloy config <path>` renders a form from this
8 + # schema, edits values in place via toml_edit, and preserves any keys
9 + # not covered here (unknown_keys = "preserve").
10 +
11 + [schema]
12 + target = "rio.toml"
13 + target_tool = "rio"
14 + target_version = ">=0.2"
15 + schema_version = "1"
16 + unknown_keys = "preserve"
17 +
18 + # -------------------------------------------------------------------
19 + # UI sections — the form renders one collapsible pane per section,
20 + # ordered as declared here. Fields fall into a section by path prefix.
21 + # -------------------------------------------------------------------
22 +
23 + [[section]]
24 + path = "navigation"
25 + description = "Rio tab / split behavior. Alloy runs Sway for tiling, so the default disables both."
26 +
27 + [[section]]
28 + path = "window"
29 + description = "Window frame, opacity, and decorations."
30 +
31 + [[section]]
32 + path = "renderer"
33 + description = "GPU backend and render performance."
34 +
35 + [[section]]
36 + path = "fonts"
37 + description = "Font family, weights, and size."
38 +
39 + [[section]]
40 + path = "cursor"
41 + description = "Cursor shape and blink."
42 +
43 + [[section]]
44 + path = "colors"
45 + description = "ANSI palette and semantic colors. Presets swap the whole palette in one action."
46 +
47 + [[section]]
48 + path = "bindings"
49 + description = "Rio-scoped keybindings. Empty by default; Sway owns global binds."
50 +
51 + # -------------------------------------------------------------------
52 + # Fields — navigation
53 + # -------------------------------------------------------------------
54 +
55 + [[field]]
56 + path = "navigation.mode"
57 + type = "enum"
58 + default = "Plain"
59 + description = "Rio-native tab and split behavior."
60 + values = [
61 + { value = "Plain", label = "Plain", description = "No tabs, no splits. Recommended under Sway." },
62 + { value = "CollapsedTab", label = "Collapsed tabs", description = "Rio-drawn tab strip, collapsed." },
63 + { value = "BottomTab", label = "Bottom tabs", description = "Rio-drawn tabs at the bottom." },
64 + { value = "TopTab", label = "Top tabs", description = "Rio-drawn tabs at the top." },
65 + ]
66 +
67 + # -------------------------------------------------------------------
68 + # Fields — window
69 + # -------------------------------------------------------------------
70 +
71 + [[field]]
72 + path = "window.background-opacity"
73 + type = "float"
74 + range = [0.0, 1.0]
75 + default = 1.0
76 + description = "Window background opacity (0 = transparent, 1 = opaque)."
77 +
78 + [[field]]
79 + path = "window.decorations"
80 + type = "enum"
81 + default = "Disabled"
82 + description = "Client-side decorations. Alloy runs Sway with server-side frame-off; leave Disabled."
83 + values = [
84 + { value = "Enabled", label = "Enabled", description = "Rio draws its own title bar." },
85 + { value = "Disabled", label = "Disabled", description = "No decorations (recommended under Sway)." },
86 + { value = "Transparent", label = "Transparent", description = "Frameless with transparency." },
87 + ]
88 +
89 + # -------------------------------------------------------------------
90 + # Fields — renderer
91 + # -------------------------------------------------------------------
92 +
93 + [[field]]
94 + path = "renderer.performance"
95 + type = "enum"
96 + default = "High"
97 + description = "Render-loop performance target. Lower reduces GPU load on battery."
98 + values = ["High", "Low"]
99 +
100 + [[field]]
101 + path = "renderer.backend"
102 + type = "enum"
103 + default = "Automatic"
104 + description = "GPU backend. Automatic lets wgpu pick vulkan / gl / metal per platform."
105 + values = ["Automatic", "Vulkan", "GL", "Metal", "DX12"]
106 +
107 + [[field]]
108 + path = "renderer.disable-unfocused-render"
109 + type = "bool"
110 + default = false
111 + description = "Skip rendering while the window is unfocused. Saves power at the cost of stale content flashes on refocus."
112 +
113 + # -------------------------------------------------------------------
114 + # Fields — fonts
115 + # -------------------------------------------------------------------
116 +
117 + [[field]]
118 + path = "fonts.size"
119 + type = "float"
120 + range = [6.0, 48.0]
121 + default = 12.0
122 + description = "Font size in points. 12 at 1080p; 13-14 on HiDPI or larger panels."
123 +
124 + [[field]]
125 + path = "fonts.family"
126 + type = "string"
127 + default = "IosevkaTerm Nerd Font"
128 + description = "Default font family. Alloy ships IosevkaTerm Nerd Font per docs/STACK.md#fonts."
129 +
130 + # Weight/style variants: repeating tables with the same shape.
131 + # Written out per variant because the paths are literal keys, not a list.
132 +
133 + [[field]]
134 + path = "fonts.regular.family"
135 + type = "string"
136 + default = "IosevkaTerm Nerd Font"
137 + description = "Regular-variant family."
138 +
139 + [[field]]
140 + path = "fonts.regular.style"
141 + type = "enum"
142 + values = ["Normal", "Italic"]
143 + default = "Normal"
144 + description = "Regular-variant style."
145 +
146 + [[field]]
147 + path = "fonts.regular.weight"
148 + type = "int"
149 + range = [100, 900]
150 + default = 400
151 + description = "Regular-variant weight (100-900, 400 = normal)."
152 +
153 + [[field]]
154 + path = "fonts.bold.family"
155 + type = "string"
156 + default = "IosevkaTerm Nerd Font"
157 + description = "Bold-variant family."
158 +
159 + [[field]]
160 + path = "fonts.bold.style"
161 + type = "enum"
162 + values = ["Normal", "Italic"]
163 + default = "Normal"
164 + description = "Bold-variant style."
165 +
166 + [[field]]
167 + path = "fonts.bold.weight"
168 + type = "int"
169 + range = [100, 900]
170 + default = 600
171 + description = "Bold-variant weight."
172 +
173 + [[field]]
174 + path = "fonts.italic.family"
175 + type = "string"
176 + default = "IosevkaTerm Nerd Font"
177 + description = "Italic-variant family."
178 +
179 + [[field]]
180 + path = "fonts.italic.style"
181 + type = "enum"
182 + values = ["Normal", "Italic"]
183 + default = "Italic"
184 + description = "Italic-variant style."
185 +
186 + [[field]]
187 + path = "fonts.italic.weight"
188 + type = "int"
189 + range = [100, 900]
190 + default = 400
191 + description = "Italic-variant weight."
192 +
193 + [[field]]
194 + path = "fonts.bold-italic.family"
195 + type = "string"
196 + default = "IosevkaTerm Nerd Font"
197 + description = "Bold-italic-variant family."
198 +
199 + [[field]]
200 + path = "fonts.bold-italic.style"
201 + type = "enum"
202 + values = ["Normal", "Italic"]
203 + default = "Italic"
204 + description = "Bold-italic-variant style."
205 +
206 + [[field]]
207 + path = "fonts.bold-italic.weight"
208 + type = "int"
209 + range = [100, 900]
210 + default = 600
211 + description = "Bold-italic-variant weight."
212 +
213 + # -------------------------------------------------------------------
214 + # Fields — cursor
215 + # -------------------------------------------------------------------
216 +
217 + [[field]]
218 + path = "cursor.shape"
219 + type = "enum"
220 + values = ["block", "underline", "beam"]
221 + default = "block"
222 + description = "Cursor shape."
223 +
224 + [[field]]
225 + path = "cursor.blinking"
226 + type = "bool"
227 + default = false
228 + description = "Whether the cursor blinks."
229 +
230 + # -------------------------------------------------------------------
231 + # Group — colors
232 + #
233 + # All 25 palette slots share type = color, format = hex. The group
234 + # expands to one field per entry at `colors.<key>`. Presets below
235 + # swap the whole palette in one action.
236 + # -------------------------------------------------------------------
237 +
238 + [[group]]
239 + path = "colors"
240 + type = "color"
241 + format = "hex"
242 + description = "Terminal palette. The Alloy Flatwhite preset is the shipped default; individual slots can be tuned or another preset applied."
243 + entries = [
244 + { key = "background", default = "#f5f2ea", description = "Surface background." },
245 + { key = "foreground", default = "#302c22", description = "Primary text." },
246 + { key = "cursor", default = "#302c22", description = "Cursor color." },
247 + { key = "selection-background", default = "#e0dbcf", description = "Selected-text background (border-subtle)." },
248 + { key = "selection-foreground", default = "#302c22", description = "Selected-text foreground." },
249 +
250 + # Normal ANSI 0-7
251 + { key = "black", default = "#302c22", description = "ANSI 0 — text-primary." },
252 + { key = "red", default = "#c25d4f", description = "ANSI 1 — accent-error." },
253 + { key = "green", default = "#5a8f52", description = "ANSI 2 — accent-ok." },
254 + { key = "yellow", default = "#d1a234", description = "ANSI 3 — accent-warn." },
255 + { key = "blue", default = "#4a86c0", description = "ANSI 4 — accent-info." },
256 + { key = "magenta", default = "#9a6bb0", description = "ANSI 5 — accent-syntax." },
257 + { key = "cyan", default = "#5f5a4e", description = "ANSI 6 — text-secondary (Alloy has no cyan accent)." },
258 + { key = "white", default = "#faf7ef", description = "ANSI 7 — surface-raised." },
259 +
260 + # Dim / bright ANSI 8-15
261 + { key = "dim-black", default = "#5f5a4e", description = "ANSI 8 — text-secondary." },
262 + { key = "dim-red", default = "#c25d4f", description = "ANSI 9." },
263 + { key = "dim-green", default = "#5a8f52", description = "ANSI 10." },
264 + { key = "dim-yellow", default = "#d1a234", description = "ANSI 11." },
265 + { key = "dim-blue", default = "#4a86c0", description = "ANSI 12." },
266 + { key = "dim-magenta", default = "#9a6bb0", description = "ANSI 13." },
267 + { key = "dim-cyan", default = "#8b8477", description = "ANSI 14 — text-muted." },
268 + { key = "dim-foreground", default = "#8b8477", description = "Dim foreground fallback." },
269 +
270 + { key = "light-black", default = "#8b8477", description = "Bright ANSI 8 — text-muted." },
271 + { key = "light-red", default = "#c25d4f", description = "Bright ANSI 9." },
272 + { key = "light-green", default = "#5a8f52", description = "Bright ANSI 10." },
273 + { key = "light-yellow", default = "#d1a234", description = "Bright ANSI 11." },
274 + { key = "light-blue", default = "#4a86c0", description = "Bright ANSI 12." },
275 + { key = "light-magenta", default = "#9a6bb0", description = "Bright ANSI 13." },
276 + { key = "light-cyan", default = "#5f5a4e", description = "Bright ANSI 14 — text-secondary." },
277 + { key = "light-foreground", default = "#302c22", description = "Bright foreground." },
278 + ]
279 +
280 + # -------------------------------------------------------------------
281 + # Presets — grouped-swap UX.
282 + #
283 + # Applying a preset writes every listed path in one atomic edit
284 + # (single dirty state, one undo entry). Alloy currently ships one:
285 + # the light-mode Flatwhite palette. A dark-mode counterpart lands
286 + # alongside the TOKENS.md dark ramp verification.
287 + # -------------------------------------------------------------------
288 +
289 + [[preset]]
290 + name = "Alloy Flatwhite (light)"
291 + description = "The shipped default. Warm cream / papery beige surface with ANSI slots mapped to Alloy accents."
292 + values = {
293 + "colors.background" = "#f5f2ea",
294 + "colors.foreground" = "#302c22",
295 + "colors.cursor" = "#302c22",
296 + "colors.selection-background" = "#e0dbcf",
297 + "colors.selection-foreground" = "#302c22",
298 + "colors.black" = "#302c22",
299 + "colors.red" = "#c25d4f",
300 + "colors.green" = "#5a8f52",
301 + "colors.yellow" = "#d1a234",
302 + "colors.blue" = "#4a86c0",
303 + "colors.magenta" = "#9a6bb0",
304 + "colors.cyan" = "#5f5a4e",
305 + "colors.white" = "#faf7ef",
306 + "colors.dim-black" = "#5f5a4e",
307 + "colors.dim-red" = "#c25d4f",
308 + "colors.dim-green" = "#5a8f52",
309 + "colors.dim-yellow" = "#d1a234",
310 + "colors.dim-blue" = "#4a86c0",
311 + "colors.dim-magenta" = "#9a6bb0",
312 + "colors.dim-cyan" = "#8b8477",
313 + "colors.dim-foreground" = "#8b8477",
314 + "colors.light-black" = "#8b8477",
315 + "colors.light-red" = "#c25d4f",
316 + "colors.light-green" = "#5a8f52",
317 + "colors.light-yellow" = "#d1a234",
318 + "colors.light-blue" = "#4a86c0",
319 + "colors.light-magenta" = "#9a6bb0",
320 + "colors.light-cyan" = "#5f5a4e",
321 + "colors.light-foreground" = "#302c22",
322 + }
323 +
324 + # -------------------------------------------------------------------
325 + # Fields — bindings (list of tables)
326 + # -------------------------------------------------------------------
327 +
328 + [[field]]
329 + path = "bindings.keys"
330 + type = "list"
331 + default = []
332 + description = "Rio-scoped keybindings. Empty by default — Sway owns global binds; add entries here only for rio-internal actions (e.g. tab navigation if you turn navigation.mode on)."
333 + element = { type = "table", fields = [
334 + { path = "key", type = "string", required = true, description = "Keychord in rio syntax (e.g. \"ctrl+shift+t\")." },
335 + { path = "action", type = "string", required = true, description = "Action to invoke (e.g. \"CreateTab\")." },
336 + { path = "mode", type = "string", required = false, description = "Optional: restrict to a rio mode." },
337 + ] }