Skip to main content

max / goingson

Render form fields in Rust, starting with the sync encryption pair The round trip that phase B needed, proven end to end on one call site. render_form_fields takes a whole form's fields and returns markup keyed by name. Batched on purpose: callers interpolate fields into larger template literals synchronously, so a command per field would make every one of those literals async and force a rewrite of the surrounding templates, which is a migration rather than an adoption. Awaiting once and reading a map leaves the templates as they were. What the app stops owning: about twelve escape calls and a per-site choice among four JS escapers, which exists only because escapeHtml is built on textContent serialization and cannot encode a double quote. One Rust encoder is sound in element text and in a double-quoted attribute alike, so the choice disappears instead of moving. Initial render also now sets aria-invalid and aria-describedby, which the runtime validation path always set and the renderer never did. renderFormField is marked deprecated rather than deleted, and stays until the remaining call sites move. The sync interval select is deliberately still on it, with the reason in a comment: it passes an inline onchange through attrs, and the emitter cannot carry an app's hook attributes yet. Both makeover crates are path-patched at the workspace root for now, so the gaps this wiring keeps finding can be released once instead of per gap.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 12:54 UTC
Signed with PGP, not checked
Commit: e6f58699b5e9169aff4471956a55042c72165623
Parent: f08b4ba
9 files changed, +279 insertions, -23 deletions
M Cargo.lock +4 -6
@@ -2248,6 +2248,8 @@
2248 2248 "mailparse",
2249 2249 "makeover",
2250 2250 "makeover-build",
2251 + "makeover-layout",
2252 + "makeover-webview",
2251 2253 "notify",
2252 2254 "notify-debouncer-mini",
2253 2255 "open",
@@ -3390,15 +3392,11 @@
3390 3392
3391 3393 [[package]]
3392 3394 name = "makeover-layout"
3393 - version = "0.3.0"
3394 - source = "registry+https://github.com/rust-lang/crates.io-index"
3395 - checksum = "e220120a09f4ee3bf8e9a0959c3cde8bcb653827eda3f885f10671623b872252"
3395 + version = "0.4.0"
3396 3396
3397 3397 [[package]]
3398 3398 name = "makeover-webview"
3399 - version = "0.5.1"
3400 - source = "registry+https://github.com/rust-lang/crates.io-index"
3401 - checksum = "ff20dd9d759cfb3b407631272f93a660f889a0103f0337256238ccdde315ac9a"
3399 + version = "0.7.0"
3402 3400 dependencies = [
3403 3401 "makeover-layout",
3404 3402 ]
M Cargo.toml +9
@@ -154,3 +154,12 @@
154 154 match_same_arms = "allow"
155 155 unnecessary_wraps = "allow"
156 156 type_complexity = "allow"
157 +
158 + # Temporary, for the phase B adoption: the forms emitter and the description
159 + # are being wired in here and released once, rather than published per gap
160 + # found. Both are also reached indirectly through makeover-build, which pins
161 + # them from the registry, so patching is what keeps one copy of each in the
162 + # tree rather than two.
163 + [patch.crates-io]
164 + makeover-layout = { path = "../../Libraries/makeover-layout" }
165 + makeover-webview = { path = "../../Libraries/makeover-webview" }
@@ -86,6 +86,11 @@
86 86
87 87 # Theme loading
88 88 makeover = { workspace = true }
89 + # The forms emitter and the description it renders. Runtime deps, not build:
90 + # form markup depends on the value, the error and the options at the moment a
91 + # modal opens, so it cannot be materialised the way the stylesheet is.
92 + makeover-webview = "0.7.0"
93 + makeover-layout = "0.4.0"
89 94 toml = { workspace = true }
90 95 # Browser opening
91 96 open = { workspace = true }
@@ -215,6 +215,8 @@
215 215 // App info
216 216 $crate::commands::get_changelog,
217 217 $crate::commands::parse_natural_date,
218 + // Form markup
219 + $crate::commands::render_form_fields,
218 220 // Window
219 221 $crate::commands::open_compose_window,
220 222 $crate::commands::set_window_title,
@@ -271,6 +271,13 @@
271 271 parseNaturalDate: (input) => invoke('parse_natural_date', { input }), // "tomorrow", "friday 3pm" -> YYYY-MM-DDTHH:MM
272 272 },
273 273
274 + // Form markup, rendered by makeover-webview. A whole form's fields in one
275 + // round trip, keyed by name: see GoingsOn.ui.renderFields, which is what UI
276 + // code should call.
277 + forms: {
278 + renderFields: (fields, idPrefix) => invoke('render_form_fields', { fields, idPrefix }),
279 + },
280 +
274 281 // Day Planning
275 282 dayPlanning: {
276 283 getDay: (date) => invoke('get_day_planning', { date }),
@@ -144,7 +144,41 @@
144 144 }
145 145
146 146 /**
147 - * Render a single form field as an HTML string. The canonical primitive for forms.
147 + * Render a whole form's fields to markup, keyed by field name.
148 + *
149 + * The replacement for `renderFormField`. The markup comes from
150 + * makeover-webview's `form::field_html`, so one Rust encoder does the escaping
151 + * that four JS escapers and a per-site human choice used to do, and the initial
152 + * render finally sets `aria-invalid` and `aria-describedby` the way the runtime
153 + * validation path already did.
154 + *
155 + * Batched deliberately. Callers interpolate fields into larger template
156 + * literals synchronously, so a per-field async call would force a rewrite of
157 + * every surrounding template. Await this once, then read the map while building
158 + * the template exactly as before:
159 + *
160 + * const f = await GoingsOn.ui.renderFields(specs, formId);
161 + * el.innerHTML = `<form>${f.title}${f.notes}</form>`;
162 + *
163 + * @param {Array<Object>} specs - Field descriptions. `kind` is one of text,
164 + * password, number, email, url, tel, textarea, select, checkbox, hidden.
165 + * Carries `name`, `label`, and optionally `hint`, `error`, `required`,
166 + * `extended`, `value`, `checked`, `options`, `placeholder`, `trailingHtml`.
167 + * @param {string} [idPrefix] - Scopes the `id` attributes to one instance of
168 + * the form, so a new-entity and an edit-entity modal do not collide. Never
169 + * applied to `name`.
170 + * @returns {Promise<Object<string,string>>} - Markup by field name.
171 + */
172 + async function renderFields(specs, idPrefix) {
173 + return GoingsOn.api.forms.renderFields(specs, idPrefix);
174 + }
175 +
176 + /**
177 + * Render a single form field as an HTML string.
178 + *
179 + * @deprecated Superseded by {@link renderFields}, which renders the same shape
180 + * from makeover-webview instead. Being retired call site by call site; do not
181 + * add new callers.
148 182 * @param {Object} field - Field definition
149 183 * @param {string} field.kind - 'text' | 'email' | 'number' | 'password' | 'date' | 'time' | 'datetime-local' | 'hidden' | 'select' | 'textarea' | 'checkbox'
150 184 * @param {string} field.name - Form input name
@@ -644,6 +678,7 @@
644 678 renderEmptyState,
645 679 emptyStateIcon,
646 680 renderFormField,
681 + renderFields,
647 682
648 683 // API wrapper
649 684 apiCall,
@@ -29,7 +29,6 @@
29 29 return;
30 30 }
31 31
32 - const renderFormField = GoingsOn.ui.renderFormField;
33 32 let sectionContent;
34 33
35 34 if (!status.configured) {
@@ -52,10 +51,21 @@
52 51 ? 'Choose a password to encrypt your synced data. You will need this password on other devices.'
53 52 : 'Enter the encryption password you set up on your first device.';
54 53
54 + // Both fields cross once, before the template is built, so the
55 + // literal below stays synchronous. No id prefix: this section is
56 + // never on screen twice.
57 + const specs = [
58 + { kind: 'password', name: 'sync-encryption-password', label: 'Encryption Password', placeholder: 'Enter password', required: true },
59 + ];
60 + if (isNewDevice) {
61 + specs.push({ kind: 'password', name: 'sync-encryption-confirm', label: 'Confirm Password', placeholder: 'Confirm password', required: true });
62 + }
63 + const f = await GoingsOn.ui.renderFields(specs);
64 +
55 65 sectionContent = `
56 66 <p class="text-secondary sync-hint">${esc(hint)}</p>
57 - ${renderFormField({ kind: 'password', name: 'sync-encryption-password', label: 'Encryption Password', placeholder: 'Enter password', required: true })}
58 - ${isNewDevice ? renderFormField({ kind: 'password', name: 'sync-encryption-confirm', label: 'Confirm Password', placeholder: 'Confirm password', required: true }) : ''}
67 + ${f['sync-encryption-password']}
68 + ${isNewDevice ? f['sync-encryption-confirm'] : ''}
59 69 <div id="sync-encryption-error" class="sync-encryption-error"></div>
60 70 <div class="sync-section-actions">
61 71 <button class="button button--primary" data-act="settings.submitEncryption" data-args='[${isNewDevice}]'>${esc(heading)}</button>
@@ -81,6 +91,27 @@
81 91 `;
82 92 } catch (_) {}
83 93
94 + // Still on the old renderer, and the reason is worth stating: this
95 + // select carries an inline onchange through `attrs`, and the
96 + // emitter has no way to carry an app's hook attributes. It cannot
97 + // become a data-change either, because the delegated dispatcher
98 + // reads el.value off whichever element holds the attribute, so
99 + // moving it to a wrapper passes undefined. Blocked until the
100 + // emitter can carry them.
101 + const intervalField = GoingsOn.ui.renderFormField({
102 + kind: 'select',
103 + name: 'sync-interval',
104 + id: 'sync-interval',
105 + label: 'Sync Interval',
106 + value: status.syncIntervalMinutes,
107 + attrs: { onchange: 'GoingsOn.settings.updateSyncSettings()' },
108 + options: intervalOptions.map(m => ({
109 + value: String(m),
110 + label: m === 1 ? '1 minute' : m + ' minutes',
111 + selected: status.syncIntervalMinutes === m,
112 + })),
113 + });
114 +
84 115 sectionContent = `
85 116 <div class="sync-status-row">
86 117 <span class="sync-status-dot"></span>
@@ -113,19 +144,7 @@
113 144 <span>Enable automatic sync</span>
114 145 </label>
115 146 </div>
116 - ${renderFormField({
117 - kind: 'select',
118 - name: 'sync-interval',
119 - id: 'sync-interval',
120 - label: 'Sync Interval',
121 - value: status.syncIntervalMinutes,
122 - attrs: { onchange: 'GoingsOn.settings.updateSyncSettings()' },
123 - options: intervalOptions.map(m => ({
124 - value: String(m),
125 - label: m === 1 ? '1 minute' : m + ' minutes',
126 - selected: status.syncIntervalMinutes === m,
127 - })),
128 - })}
147 + ${intervalField}
129 148 </div>
130 149
131 150 <div class="section-divider">
@@ -28,6 +28,7 @@
28 28 pub mod error;
29 29 mod event;
30 30 mod export;
31 + mod form;
31 32 mod group;
32 33 mod import;
33 34 mod import_external;
@@ -126,6 +127,7 @@
126 127 pub use email_sync::*;
127 128 pub use event::*;
128 129 pub use export::*;
130 + pub use form::*;
129 131 pub use group::*;
130 132 pub use import::*;
131 133 pub use import_external::*;
@@ -1,0 +1,179 @@
1 + //! Form markup rendering.
2 + //!
3 + //! One command, and the only interesting thing about it is why it exists at
4 + //! all. Field markup used to be built in `js/components.js:renderFormField`,
5 + //! which held about twelve escape calls and picked among four JS escapers by
6 + //! hand at each one. That choice is unnecessary in Rust: `form::escape` encodes
7 + //! all five characters and is sound in element text and in a double-quoted
8 + //! attribute alike, where `escapeHtml` is built on `textContent` serialization
9 + //! and cannot encode a double quote. So the four-way choice disappears rather
10 + //! than moving.
11 + //!
12 + //! # Why a batch
13 + //!
14 + //! The frontend interpolates rendered fields into larger string-built forms,
15 + //! inside template literals, synchronously. A command per field would make
16 + //! every one of those literals async and force a rewrite of the surrounding
17 + //! templates, which is a migration rather than an adoption. So a form's fields
18 + //! cross once, together, and the caller reads the results out of a map by name
19 + //! while building its template exactly as before.
20 +
21 + use std::collections::HashMap;
22 +
23 + use makeover_layout::{Field, FieldKind};
24 + use makeover_webview::Emit;
25 + use makeover_webview::form::{Choice, Filling, Markup, Value, field_html};
26 + use serde::Deserialize;
27 + use tracing::{instrument, warn};
28 +
29 + /// One option of a select, as the frontend sends it.
30 + #[derive(Debug, Deserialize)]
31 + pub struct ChoiceSpec {
32 + /// What is submitted.
33 + value: String,
34 + /// What is read. Defaults to the value, matching `Choice::plain`.
35 + #[serde(default)]
36 + label: Option<String>,
37 + }
38 +
39 + /// One field, as the frontend sends it.
40 + ///
41 + /// A mirror of [`Field`] plus everything the description deliberately does not
42 + /// carry: the current value, the placeholder, the select's options. Those are
43 + /// renderer-side state and arrive in [`Filling`].
44 + #[derive(Debug, Deserialize)]
45 + #[serde(rename_all = "camelCase")]
46 + pub struct FieldSpec {
47 + /// `text`, `password`, `number`, `email`, `url`, `tel`, `textarea`,
48 + /// `select`, `checkbox` or `hidden`. Unknown kinds render as text.
49 + kind: String,
50 + /// The name the value submits under.
51 + name: String,
52 + /// What the user is asked for.
53 + #[serde(default)]
54 + label: String,
55 + /// Standing help.
56 + #[serde(default)]
57 + hint: Option<String>,
58 + /// What is currently wrong.
59 + #[serde(default)]
60 + error: Option<String>,
61 + /// Whether the form refuses to submit without it.
62 + #[serde(default)]
63 + required: bool,
64 + /// Whether it lives behind a "more options" disclosure.
65 + #[serde(default)]
66 + extended: bool,
67 + /// The current value, for everything that takes typed text.
68 + #[serde(default)]
69 + value: Option<String>,
70 + /// The current state, for a checkbox.
71 + #[serde(default)]
72 + checked: Option<bool>,
73 + /// The options, for a select.
74 + #[serde(default)]
75 + options: Option<Vec<ChoiceSpec>>,
76 + /// Ghost text shown while empty.
77 + #[serde(default)]
78 + placeholder: Option<String>,
79 + /// Markup appended after the hint. NOT escaped.
80 + ///
81 + /// The caller is stating the contents are trusted, which is what
82 + /// [`Markup`] exists to make them say out loud. Two live callers need it,
83 + /// both passing a recurrence block built elsewhere.
84 + #[serde(default)]
85 + trailing_html: Option<String>,
86 + }
87 +
88 + /// Map the frontend's kind string onto the description.
89 + ///
90 + /// Unknown kinds fall back to text rather than failing the render. A form that
91 + /// draws with one field slightly wrong is recoverable; a form that does not
92 + /// draw is not, and the warning says which happened.
93 + fn kind_of(spec_kind: &str) -> FieldKind {
94 + match spec_kind {
95 + "password" => FieldKind::Secret,
96 + "number" => FieldKind::Number,
97 + "email" => FieldKind::Email,
98 + "url" => FieldKind::Url,
99 + "tel" => FieldKind::Tel,
100 + "textarea" => FieldKind::Textarea,
101 + "select" => FieldKind::Select,
102 + "checkbox" => FieldKind::Checkbox,
103 + "hidden" => FieldKind::Hidden,
104 + "text" => FieldKind::Text,
105 + other => {
106 + warn!(kind = other, "unknown field kind, rendering as text");
107 + FieldKind::Text
108 + }
109 + }
110 + }
111 +
112 + /// Render a form's fields to markup, keyed by field name.
113 + ///
114 + /// `id_prefix` scopes the `id` attributes to one instance of the form, which
115 + /// matters because the new-entity and edit-entity modals are the same field set
116 + /// and their ids would otherwise collide. It never reaches `name`.
117 + ///
118 + /// Infallible by construction: every field renders to something, and an
119 + /// unrecognised kind renders as text with a warning rather than taking the form
120 + /// down with it.
121 + #[tauri::command]
122 + #[instrument(skip(fields), fields(count = fields.len()))]
123 + pub async fn render_form_fields(
124 + fields: Vec<FieldSpec>,
125 + id_prefix: Option<String>,
126 + ) -> HashMap<String, String> {
127 + let opts = Emit::default();
128 + let mut out = HashMap::with_capacity(fields.len());
129 +
130 + for spec in &fields {
131 + let kind = kind_of(&spec.kind);
132 +
133 + let field = Field {
134 + kind,
135 + name: &spec.name,
136 + label: &spec.label,
137 + hint: spec.hint.as_deref(),
138 + error: spec.error.as_deref(),
139 + required: spec.required,
140 + extended: spec.extended,
141 + };
142 +
143 + // Built here rather than inline so the borrow outlives the Value that
144 + // points into it.
145 + let choices: Vec<Choice<'_>> = spec
146 + .options
147 + .as_deref()
148 + .unwrap_or_default()
149 + .iter()
150 + .map(|opt| Choice {
151 + value: &opt.value,
152 + label: opt.label.as_deref().unwrap_or(&opt.value),
153 + })
154 + .collect();
155 +
156 + let value = match kind {
157 + FieldKind::Checkbox => Value::On(spec.checked.unwrap_or_default()),
158 + FieldKind::Select => Value::Chosen {
159 + options: &choices,
160 + value: spec.value.as_deref().unwrap_or_default(),
161 + },
162 + _ => match spec.value.as_deref() {
163 + Some(text) => Value::Text(text),
164 + None => Value::Absent,
165 + },
166 + };
167 +
168 + let filling = Filling {
169 + value,
170 + placeholder: spec.placeholder.as_deref(),
171 + trailing: spec.trailing_html.as_deref().map(Markup),
172 + id_prefix: id_prefix.as_deref(),
173 + };
174 +
175 + out.insert(spec.name.clone(), field_html(&field, &filling, &opts));
176 + }
177 +
178 + out
179 + }