POST /proposals answers a refusal with back(made.message, "/") — a 303 to the front page carrying a notice cookie. The title, up to two thousand characters of paragraph, every option and the chosen rule are gone, and the person is looking at a list with a toast on it. The remark forms do the same through done(...). Somebody who writes a careful proposal and mistypes one option label loses all of it, and the longer and more considered the writing, the more there is to lose.
What done looks like
[ ] A refused write re-renders the form with every value the person submitted still in it, at the address they posted to, with status 422 — not a redirect. A successful write keeps the redirect it has today, so the browser's back button and the post-redirect-get pattern are untouched where they work.
[ ] The message sits beside the field it is about, not only at the top: the action's named Failure grows an optional field (title, body, options, rule, named, option_label, kind) so the page can put the sentence where the fix is. A failure with no field renders where the toast does now.
[ ] Every value is escaped into its control — a <textarea> holding </textarea><script>, a title holding quotes, an option list holding & — and there is a test that posts exactly that and reads the value back out of the re-rendered form.
[ ] Both composers: the proposal form (title, paragraph, options, rule and its names) and the remark form (kind, body, option label). A refused remark comes back on the proposal page, in the thread's own composer, with what was typed.
[ ] The re-rendered page is the real page — the same layout, the same live regions — not a stripped error page, so somebody can fix the field and post again without navigating.
[ ] The live region must not swallow it: a page rendered with unsaved input in it is exactly the case #49 protects, so a refetch triggered by somebody else's event while a refused form is on screen leaves the typed values alone. Add the test #49's work makes possible.
[ ] A 413 (a body over the cap) says what the limit is and keeps whatever did arrive if anything did; if nothing did, it says plainly that the text was too large to accept, with the number.
[ ] Tests in src/server.test.ts: a proposal refused for a bad rule comes back 422 with the title and paragraph still present and the message beside the rule field; a remark refused for an empty body comes back on its proposal with the kind still chosen; the escape case above; a successful post still 303s.
[ ] bun test is green.
Files I expect to change:src/actions.ts (the field on Failure), src/routes.ts, src/pages/home.ts, src/pages/proposal.ts, src/server.test.ts, static/app.css, possibly src/http.ts.
Risks: the composer's markup currently lives wherever the page builds it — pull it into one function per form that takes the values and the error before changing the routes, or the "empty" and "refused" versions will drift apart. Re-rendering a POST means a reload re-posts: that is the accepted cost of not losing the text, and it applies only to the refusal path. Do not answer 200 for a refusal — a script reading the same route must still see a failure status.
Fit: Hearth — the app respecting the person who took the time to write something. A person assigns; I do not.
POST /proposalsanswers a refusal withback(made.message, "/")— a 303 to the front page carrying a notice cookie. The title, up to two thousand characters of paragraph, every option and the chosen rule are gone, and the person is looking at a list with a toast on it. The remark forms do the same throughdone(...). Somebody who writes a careful proposal and mistypes one option label loses all of it, and the longer and more considered the writing, the more there is to lose.What done looks like
Failuregrows an optionalfield(title,body,options,rule,named,option_label,kind) so the page can put the sentence where the fix is. A failure with no field renders where the toast does now.<textarea>holding</textarea><script>, a title holding quotes, an option list holding&— and there is a test that posts exactly that and reads the value back out of the re-rendered form.layout, the same live regions — not a stripped error page, so somebody can fix the field and post again without navigating.src/server.test.ts: a proposal refused for a bad rule comes back 422 with the title and paragraph still present and the message beside the rule field; a remark refused for an empty body comes back on its proposal with the kind still chosen; the escape case above; a successful post still 303s.bun testis green.Files I expect to change:
src/actions.ts(thefieldonFailure),src/routes.ts,src/pages/home.ts,src/pages/proposal.ts,src/server.test.ts,static/app.css, possiblysrc/http.ts.Risks: the composer's markup currently lives wherever the page builds it — pull it into one function per form that takes the values and the error before changing the routes, or the "empty" and "refused" versions will drift apart. Re-rendering a POST means a reload re-posts: that is the accepted cost of not losing the text, and it applies only to the refusal path. Do not answer 200 for a refusal — a script reading the same route must still see a failure status.
Fit: Hearth — the app respecting the person who took the time to write something. A person assigns; I do not.