Three steps forward, one step back: a maintenance session
I sat down to add checkboxes to the demo HTML. Four hours later I'd rewritten the logging system, rewritten the security policy, and deleted more code than I wrote. Classic.
The checkbox detour
It started simple. The demo form had name, email, message - I wanted a checkbox group too. Something like "Contact methods: Email / Phone / Mail."
I added the HTML, updated the field definition, tested it. The runtime already handled it fine - collectData detected first.type === "checkbox" and returned an array of checked values. Done.
Then I overthought it. "Why is the field type text when it's clearly checkboxes?" So I added "checkboxes" as a first-class field type. New entry in FIELD_TYPES. New validation branch. New registration in the CLI.
Then I looked at what I'd actually gained: skipping maxLength and email/tel regex on array values - which were already non-issues because nobody sets maxLength on checkboxes. The whole thing boiled down to one early-return branch that existed only because I'd created a type to justify it.
I deleted the whole thing and went back to type: text. The form worked the same before and after.
Most features don't need a new abstraction. They need the existing system to not break when handed something unexpected - and if it doesn't break, leave it alone.
The config error that wouldn't stop cascading
When fse.config.js had a syntax error, the console would fill with cryptic noise. A raw SyntaxError, then [fse/form] FSE is not defined, then [fse] Mount failed. Nothing useful.
The original fix was a try/catch around document.head.appendChild(s). Works in Chrome. In Firefox, script parse errors from dynamically inserted scripts fire outside the try scope - so Firefox users got the full noisy cascade while Chrome users got a clean error. I didn't catch this because I test in Chrome.
Real fix: pre-parse the config with new Function(code) before injecting. If it throws, print the friendly message and bail.
try {
new Function(code);
} catch (e) {
FSE_LOG.error("fse.config.js has a syntax error", "Check for missing braces or commas.");
loadNext(index + 1);
return;
}
s.textContent = code;
document.head.appendChild(s);
Then the mount guard broke. When config parsing fails, window.FSE is never defined - but freeze.js still runs. structuredClone(undefined) returns undefined, so window.__fse_internal__ gets created with FSE: undefined. My guard checked if (!internal && !window.FSE) - but internal was truthy (an object), so the guard passed right through and mount failed anyway.
// Before (broken):
if (!internal && !window.FSE) { return; }
// After:
var cfg = window.FSE || (internal && internal.FSE);
if (!cfg) { return; }
Then the load order broke. fse.log.js was loading after fse.config.js, so when config parsing failed and called FSE_LOG.error() - FSE_LOG didn't exist yet. Moved it before both config files.
Three bugs, each hidden until the previous one was fixed. That's the pattern.
The logging cleanup
The codebase had console.* calls scattered across four files with inconsistent prefixes - [fse], [fse/form], [fse/validate]. Some had fix suggestions, most didn't.
I built FSE_LOG - a central logger with error, warn, and info. Errors always show. Warnings and info are gated behind a logging flag in config (default true). Every call gets a [formseal] prefix. Errors and warnings include a companion fix line.
Also added the submission UUID to the submit log - it was already in the payload, just wasn't surfaced. Now it shows: [formseal] Submitted | id: <uuid> | ciphertext: <truncated>.
And fse set logging true|false in the CLI. A friend pointed out it was case-sensitive - fse set logging True would silently fail. Fixed.
15 scattered console calls → one module. Not exciting, but it makes the next bug easier to find.
The security policy review
I merged two security docs that said the same thing in different words. Someone with actual security experience read the result and gave detailed feedback.
I'd written things like "third-party scripts cannot mutate them after load" and "private key is never on the same machine." Statements that sound strong but aren't strictly true. A compromised browser can still hook inputs before encryption. You can't guarantee where someone deploys their private key. I'd also missed malicious host pages as a threat vector and said nothing about supply chain / JS delivery integrity.
They also called out the "72-hour response timeline" as corporate theater for a solo project. They were right.
Full list of things they caught
- "Even without HTTPS, intercepted submissions are opaque" → implies HTTPS isn't needed
- "Third-party scripts cannot mutate them after load" → too absolute
- "Private key is never on the same machine" → can't guarantee deployment context
- "Analytics tools never see plaintext" → only true if you define "analytics" narrowly
- Missing: malicious host pages as a threat vector
- Missing: supply chain / JS delivery integrity
- 72-hour response timeline → not realistic for a solo project
I replaced the timeline with "Response times are not guaranteed, but security reports will be reviewed as soon as possible" and rewrote the overclaims.
The difference between a security document that sounds impressive and one that's useful is how honest it is about its limits.
What actually shipped
- Checkboxes in the demo - reverted the special type, kept the HTML
- Config syntax errors -
new Function()pre-parse, fixed mount guard, fixed load order - Centralized logger -
FSE_LOG,[formseal]prefix, fix lines,loggingflag fse set logging- case-insensitive- Submit log now includes UUID
- SECURITY.md - merged and rewritten
- Bumped to v3.9.0 / v3.9.1
Maintenance sessions feel unproductive while you're in them. But the config error that would have confused a new user now prints a fix suggestion. The security policy that overstated guarantees now doesn't. The scattered console calls are one module. None of these were bugs - they were friction.