Public: anyone can read this; the crew can change it.

A refused write gives the words back: nobody retypes a paragraph because a field was wrong #58

open not picked up yet opened this
opened this

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs