Add content gets its own page; the index keeps search and the list
Paul, 2026-09-29: "remove the Add forms from the Memoria index … that should be a separate Add content form." GET /add is a new page, "Add content", carrying the form, the drop zone, the upload tray and the outcome message — moved from the index verbatim, comments and all, so their behaviour does not change. POST /documents still receives them and now redirects back to /add?stored=… or /add?err=… rather than to the index. The index keeps search and the document list, and gains an "Add content" button above the list. A failed delete still redirects to the index with its message, so the index keeps a message slot for that alone. drop.js is loaded by the layout and re-fetches whichever page it is on, so it needed no change; the tray's stale-reload link now points at /add. /add is declared in the routes gate and, rendering the layout, is swept by the footer gate automatically. Tests moved with the form: every drop and tray test that reads the form, drop zone, tray or drop script now reads /add; the literal redirect expectations say /add; and the tests that compare the outcome sentence with the document list now read the outcome from /add, where the redirect lands, and the list from the index — the comparison itself is unchanged. The three app tests that count documents on the index stay there. Driven: the index has no form and an "Add content" button; /add has the form; a scriptless paste answers 303 /add?stored=1. The browser tool could not reach the local drive server, so the page has not been looked at visually yet. Unrelated and pre-existing: TestWiringStartsTheCountingClock failed once under a full-suite run and passes alone six times in six, on this branch and on the sign-in branch. Intermittent; not chased here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+209
−163
internal/memoria/app.go+1 −0internal/memoria/handlers.go+22 −5internal/memoria/lag_test.go+1 −1internal/memoria/render.go+1 −1internal/memoria/templates/add.html+134 −0internal/memoria/templates/index.html+4 −124internal/memoriatest/app_test.go+1 −1internal/memoriatest/drop_test.go+34 −22internal/memoriatest/gates_test.go+3 −3internal/memoriatest/routes_test.go+2 −0internal/memoriatest/tray_test.go+6 −6
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index 150b625..50b44f8 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -141,6 +141,7 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| r.Post("/api/native/search", a.nativeSearch) |
| r.Post("/api/native/notes", a.nativeNote) |
| r.Get("/", a.index) |
| + r.Get("/add", a.addContent) |
| r.Get("/search", a.searchResults) |
| r.Post("/documents", a.createDocument) |
| r.Get("/documents/{id}", a.showDocument) |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 3b475c1..2f4cdc6 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -558,13 +558,11 @@ func (a *app) chunkCount() int { |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| - drop := newDropZone() |
| p := page{ |
| Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), |
| Chunks: a.chunkCount(), |
| Query: q, |
| Searched: q != "", |
| - Drop: drop, |
| } |
| a.runSearch(r, &p) |
| docs, err := a.store.Summaries(r.Context()) |
| @@ -572,6 +570,25 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| a.logger.Error("list documents", "err", err) |
| } |
| p.Documents = docs |
| + // Adding content moved to /add; the one message that still lands here |
| + // is a failed delete, which travels in the flash cookie. |
| + if fl, ok := flash.Take(w, r); ok && fl.Message != "" { |
| + p.Flash, p.FlashKind = fl.Message, fl.Kind |
| + } |
| + render(w, "index", p) |
| +} |
| + |
| +// addContent is GET /add: the page for adding content — pasting, choosing |
| +// or dropping files — with the outcome of the last submission. It used to |
| +// be a section of the index; Paul asked for it to be its own page |
| +// (2026-09-29). POST /documents redirects back here. |
| +func (a *app) addContent(w http.ResponseWriter, r *http.Request) { |
| + drop := newDropZone() |
| + p := page{ |
| + Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), |
| + Chunks: a.chunkCount(), |
| + Drop: drop, |
| + } |
| if f := r.URL.Query().Get("stored"); f != "" { |
| p.Flash, p.FlashKind = drop.outcomeCounts(count(f), count(r.URL.Query().Get("refused"))) |
| } |
| @@ -592,7 +609,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| if fl, ok := flash.Take(w, r); ok && fl.Message != "" { |
| p.Flash, p.FlashKind = fl.Message, fl.Kind |
| } |
| - render(w, "index", p) |
| + render(w, "add", p) |
| } |
| // searchResults renders the results region alone, for search.js. |
| @@ -937,7 +954,7 @@ func (a *app) createDocument(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| flash.Set(w, kind, msg) |
| - target := "/?stored=" + strconv.Itoa(stored) |
| + target := "/add?stored=" + strconv.Itoa(stored) |
| if len(refused) > 0 { |
| target += "&refused=" + strconv.Itoa(len(refused)) |
| } |
| @@ -995,7 +1012,7 @@ func (a *app) refuse(w http.ResponseWriter, r *http.Request, code string, n int) |
| return |
| } |
| flash.Set(w, "danger", msg) |
| - target := "/?err=" + code |
| + target := "/add?err=" + code |
| if n > 0 { |
| target += "&n=" + strconv.Itoa(n) |
| } |
diff --git a/internal/memoria/lag_test.go b/internal/memoria/lag_test.go| index d5662d6..64faee5 100644 |
| --- a/internal/memoria/lag_test.go |
| +++ b/internal/memoria/lag_test.go |
| @@ -196,7 +196,7 @@ func TestTheScriptlessRedirectDoesNotCountAStoredDocumentAsRefused(t *testing.T) |
| "committed document any more", len(docs)) |
| } |
| loc := rec.Header().Get("Location") |
| - if loc != "/?stored=1" { |
| + if loc != "/add?stored=1" { |
| t.Errorf("the scriptless path redirected to %s after storing %d document. The counts in "+ |
| "the URL are the fallback for a lost flash cookie, and a stored document "+ |
| "counted as refused there is the same lie in fewer words.", loc, len(docs)) |
diff --git a/internal/memoria/render.go b/internal/memoria/render.go| index 7b9d4f1..d9b89ad 100644 |
| --- a/internal/memoria/render.go |
| +++ b/internal/memoria/render.go |
| @@ -52,7 +52,7 @@ func funcs() template.FuncMap { |
| } |
| func init() { |
| - for _, name := range []string{"index", "document", "signin", "signin_oauth", "connectors", "notion", "home", "credits"} { |
| + for _, name := range []string{"index", "add", "document", "signin", "signin_oauth", "connectors", "notion", "home", "credits"} { |
| pages[name] = template.Must(template.New("layout"). |
| Funcs(funcs()). |
| ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html")) |
diff --git a/internal/memoria/templates/add.html b/internal/memoria/templates/add.html| new file mode 100644 |
| index 0000000..7cb8c24 |
| --- /dev/null |
| +++ b/internal/memoria/templates/add.html |
| @@ -0,0 +1,134 @@ |
| +{{define "title"}}Add content — memoria{{end}} |
| +{{define "content"}} |
| +{{/* Adding content, on its own page (2026-09-29, Paul: "remove the Add |
| + forms from the index … that should be a separate Add content form"). |
| + The form, the drop zone, the upload tray and the outcome message moved |
| + here unchanged; POST /documents still receives them, and now redirects |
| + back HERE with its outcome rather than to the index. The index keeps |
| + search and the document list, and links here. */}} |
| +<p><a href="/">← Back to search</a></p> |
| +<section rst-section> |
| + <h1>Add content</h1> |
| + <form method="post" action="/documents" enctype="multipart/form-data" rst-stack> |
| + <label for="title">Title <span rst-cell-mut>{{.Drop.TitleHint}}</span></label> |
| + <input type="text" id="title" name="title"> |
| + <label for="body">Paste text</label> |
| + <textarea id="body" name="body" rows="8" placeholder="Paste a document, notes, minutes…"></textarea> |
| + <label for="file">{{.Drop.FileLabel}}</label> |
| + {{/* The drop zone WRAPS the file input, it does not replace it. With |
| + no script the div is an unstyled box, the hint and the note stay |
| + hidden, and this is the same form it was: choose files, press |
| + Store it. static/drop.js adds rst-dropzone="ready", which is what |
| + the styling keys off — so a page whose script did not load never |
| + draws a drop target that cannot take a drop. |
| + |
| + What a DROP does has changed: it no longer fills this input and |
| + wait for Store it. Each dropped file is posted on its own, in |
| + the background, to this form's own action, and the tray below |
| + the form says how each one went. Choosing files here and |
| + pressing Store it is untouched and still posts the whole form. |
| + |
| + The data attributes are the handler's own limits and the |
| + handler's own words (see dropZone in handlers.go). The browser |
| + reads them from here rather than carrying a second copy, so it |
| + cannot come to refuse what the server would accept. |
| + |
| + The input is `multiple`, and that is a plain HTML affordance |
| + with no script behind it: createDocument iterates every part |
| + named "file" and stores one document each, so a scriptless |
| + chooser that hands it five files gets five documents. It was |
| + deliberately single-file while the handler read only the first |
| + part — four of five would have vanished without a word. */}} |
| + {{with .Drop}} |
| + <div rst-dropzone |
| + data-max-bytes="{{.MaxBytes}}" |
| + data-max-total-bytes="{{.MaxTotalBytes}}" |
| + data-max-files="{{.MaxFiles}}" |
| + data-sniff-bytes="{{.SniffBytes}}" |
| + data-max-in-flight="{{.MaxInFlight}}" |
| + data-msg-toomany="{{.TooManyFiles}}" |
| + data-msg-toototal="{{.TooMuchAtOnce}}" |
| + data-msg-folder="{{.Folder}}" |
| + data-msg-blocked="{{.Blocked}}" |
| + data-msg-waiting="{{.Waiting}}" |
| + data-msg-uploading="{{.Uploading}}" |
| + data-msg-storing="{{.Storing}}" |
| + data-msg-row-stored="{{.RowStored}}" |
| + data-msg-stored-one="{{.StoredOne}}" |
| + data-msg-stored-many="{{.StoredMany}}" |
| + data-msg-none-stored="{{.NoneStored}}" |
| + data-msg-fresh="{{.Fresh}}" |
| + data-msg-refused-one="{{.RefusedOne}}" |
| + data-msg-refused-many="{{.RefusedMany}}" |
| + data-msg-unsure-one="{{.UnsureOne}}" |
| + data-msg-unsure-many="{{.UnsureMany}}" |
| + data-reason-toobig="{{.ReasonTooBig}}" |
| + data-reason-binary="{{.ReasonBinary}}" |
| + data-reason-empty="{{.ReasonEmpty}}" |
| + data-reason-unreadable="{{.ReasonUnreadable}}" |
| + data-reason-noanswer="{{.ReasonNoAnswer}}"> |
| + <input type="file" id="file" name="file" multiple accept=".txt,.md,.markdown,.csv,text/plain,text/markdown"> |
| + {{/* No rst-cell-mut here: that utility is a class, `.rst-cell-mut`, |
| + and spelled as an attribute it styles nothing at all — the hint |
| + would have looked muted in the markup and read as body text on |
| + the screen. The class itself is wrong for a sentence anyway |
| + (nowrap plus an ellipsis, written for a table cell), so the |
| + muting lives in memoria.css on [rst-dropzone-hint]. |
| + |
| + Both paragraphs are `hidden` so a scriptless page promises no |
| + drop target. drop.js unhides the note while it is still EMPTY, |
| + before it ever writes into it: a live region that enters the |
| + accessibility tree in the same tick as its first message is |
| + commonly not announced at all, and a refusal nobody hears is |
| + the one outcome this feature must not have. */}} |
| + <p rst-dropzone-hint hidden>{{.Hint}}</p> |
| + <p rst-dropzone-note role="status" aria-live="polite" hidden></p> |
| + </div> |
| + {{end}} |
| + <div rst-form-bar><button rst-btn="primary">Store it</button></div> |
| + </form> |
| + {{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| + |
| + {{/* The upload tray. It lives OUTSIDE the form on purpose: it is not |
| + something the form submits, and a scriptless browser must find the |
| + form exactly as it was. Scriptless it is `hidden` and empty, so it |
| + is not on the page at all. |
| + |
| + drop.js unhides it — still empty — at its ready step, for the same |
| + reason it unhides the note there: [rst-tray-status] is the live |
| + region that announces what a drop did, and a live region that |
| + enters the accessibility tree in the same tick as its first |
| + message is commonly not announced at all. Only [rst-tray-status] |
| + is live. The rows are not, and must not be: five files uploading |
| + is five rows changing several times a second, and a live region |
| + over that is a screen reader nobody can use. |
| + |
| + Every word here comes from dropZone in handlers.go, like the data |
| + attributes above — the tray and the flash sentence a scriptless |
| + browser sees are the same strings, so they cannot drift. |
| + |
| + The reload link is a real link with a real href, and nothing |
| + reloads on its own: someone mid-drop does not want the ground |
| + moving under them. drop.js repoints it at the current URL so a |
| + reload keeps the search that is on screen. |
| + |
| + That link and the sentence beside it are the FALLBACK now rather |
| + than the usual case. When a drop settles, drop.js fetches this |
| + same page again and swaps the rst-refresh regions; the sentence is |
| + shown first and only an answer actually put on the page takes it |
| + away, so every way that fetch can fail leaves exactly what was |
| + here before it existed. Watched failing, on purpose, in a real |
| + browser — see refresh_test.go's header. */}} |
| + {{with .Drop}} |
| + <div rst-tray hidden role="region" aria-labelledby="tray-title"> |
| + <div rst-tray-head> |
| + <span rst-tray-title id="tray-title">{{.TrayTitle}}</span> |
| + <button type="button" rst-btn="quiet compact" rst-tray-dismiss>{{.TrayDismiss}}</button> |
| + </div> |
| + <ul rst-tray-list></ul> |
| + <p rst-tray-status role="status" aria-live="polite"></p> |
| + <p rst-tray-stale hidden>{{.Stale}} <a rst-tray-reload href="/add">{{.Reload}}</a></p> |
| + </div> |
| + {{end}} |
| +</section> |
| +{{end}} |
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html| index 6ced7c3..d9d0116 100644 |
| --- a/internal/memoria/templates/index.html |
| +++ b/internal/memoria/templates/index.html |
| @@ -46,133 +46,13 @@ |
| rst-search-results-for="{{.Query}}" |
| rst-search-stale-note="{{template "searchFailed"}}">{{template "results" .}}</div> |
| -<section rst-section> |
| - <h2>Add something</h2> |
| - <form method="post" action="/documents" enctype="multipart/form-data" rst-stack> |
| - <label for="title">Title <span rst-cell-mut>{{.Drop.TitleHint}}</span></label> |
| - <input type="text" id="title" name="title"> |
| - <label for="body">Paste text</label> |
| - <textarea id="body" name="body" rows="8" placeholder="Paste a document, notes, minutes…"></textarea> |
| - <label for="file">{{.Drop.FileLabel}}</label> |
| - {{/* The drop zone WRAPS the file input, it does not replace it. With |
| - no script the div is an unstyled box, the hint and the note stay |
| - hidden, and this is the same form it was: choose files, press |
| - Store it. static/drop.js adds rst-dropzone="ready", which is what |
| - the styling keys off — so a page whose script did not load never |
| - draws a drop target that cannot take a drop. |
| - |
| - What a DROP does has changed: it no longer fills this input and |
| - wait for Store it. Each dropped file is posted on its own, in |
| - the background, to this form's own action, and the tray below |
| - the form says how each one went. Choosing files here and |
| - pressing Store it is untouched and still posts the whole form. |
| - |
| - The data attributes are the handler's own limits and the |
| - handler's own words (see dropZone in handlers.go). The browser |
| - reads them from here rather than carrying a second copy, so it |
| - cannot come to refuse what the server would accept. |
| - |
| - The input is `multiple`, and that is a plain HTML affordance |
| - with no script behind it: createDocument iterates every part |
| - named "file" and stores one document each, so a scriptless |
| - chooser that hands it five files gets five documents. It was |
| - deliberately single-file while the handler read only the first |
| - part — four of five would have vanished without a word. */}} |
| - {{with .Drop}} |
| - <div rst-dropzone |
| - data-max-bytes="{{.MaxBytes}}" |
| - data-max-total-bytes="{{.MaxTotalBytes}}" |
| - data-max-files="{{.MaxFiles}}" |
| - data-sniff-bytes="{{.SniffBytes}}" |
| - data-max-in-flight="{{.MaxInFlight}}" |
| - data-msg-toomany="{{.TooManyFiles}}" |
| - data-msg-toototal="{{.TooMuchAtOnce}}" |
| - data-msg-folder="{{.Folder}}" |
| - data-msg-blocked="{{.Blocked}}" |
| - data-msg-waiting="{{.Waiting}}" |
| - data-msg-uploading="{{.Uploading}}" |
| - data-msg-storing="{{.Storing}}" |
| - data-msg-row-stored="{{.RowStored}}" |
| - data-msg-stored-one="{{.StoredOne}}" |
| - data-msg-stored-many="{{.StoredMany}}" |
| - data-msg-none-stored="{{.NoneStored}}" |
| - data-msg-fresh="{{.Fresh}}" |
| - data-msg-refused-one="{{.RefusedOne}}" |
| - data-msg-refused-many="{{.RefusedMany}}" |
| - data-msg-unsure-one="{{.UnsureOne}}" |
| - data-msg-unsure-many="{{.UnsureMany}}" |
| - data-reason-toobig="{{.ReasonTooBig}}" |
| - data-reason-binary="{{.ReasonBinary}}" |
| - data-reason-empty="{{.ReasonEmpty}}" |
| - data-reason-unreadable="{{.ReasonUnreadable}}" |
| - data-reason-noanswer="{{.ReasonNoAnswer}}"> |
| - <input type="file" id="file" name="file" multiple accept=".txt,.md,.markdown,.csv,text/plain,text/markdown"> |
| - {{/* No rst-cell-mut here: that utility is a class, `.rst-cell-mut`, |
| - and spelled as an attribute it styles nothing at all — the hint |
| - would have looked muted in the markup and read as body text on |
| - the screen. The class itself is wrong for a sentence anyway |
| - (nowrap plus an ellipsis, written for a table cell), so the |
| - muting lives in memoria.css on [rst-dropzone-hint]. |
| - |
| - Both paragraphs are `hidden` so a scriptless page promises no |
| - drop target. drop.js unhides the note while it is still EMPTY, |
| - before it ever writes into it: a live region that enters the |
| - accessibility tree in the same tick as its first message is |
| - commonly not announced at all, and a refusal nobody hears is |
| - the one outcome this feature must not have. */}} |
| - <p rst-dropzone-hint hidden>{{.Hint}}</p> |
| - <p rst-dropzone-note role="status" aria-live="polite" hidden></p> |
| - </div> |
| - {{end}} |
| - <div rst-form-bar><button rst-btn="primary">Store it</button></div> |
| - </form> |
| - {{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| - |
| - {{/* The upload tray. It lives OUTSIDE the form on purpose: it is not |
| - something the form submits, and a scriptless browser must find the |
| - form exactly as it was. Scriptless it is `hidden` and empty, so it |
| - is not on the page at all. |
| - |
| - drop.js unhides it — still empty — at its ready step, for the same |
| - reason it unhides the note there: [rst-tray-status] is the live |
| - region that announces what a drop did, and a live region that |
| - enters the accessibility tree in the same tick as its first |
| - message is commonly not announced at all. Only [rst-tray-status] |
| - is live. The rows are not, and must not be: five files uploading |
| - is five rows changing several times a second, and a live region |
| - over that is a screen reader nobody can use. |
| - |
| - Every word here comes from dropZone in handlers.go, like the data |
| - attributes above — the tray and the flash sentence a scriptless |
| - browser sees are the same strings, so they cannot drift. |
| - |
| - The reload link is a real link with a real href, and nothing |
| - reloads on its own: someone mid-drop does not want the ground |
| - moving under them. drop.js repoints it at the current URL so a |
| - reload keeps the search that is on screen. |
| - |
| - That link and the sentence beside it are the FALLBACK now rather |
| - than the usual case. When a drop settles, drop.js fetches this |
| - same page again and swaps the rst-refresh regions; the sentence is |
| - shown first and only an answer actually put on the page takes it |
| - away, so every way that fetch can fail leaves exactly what was |
| - here before it existed. Watched failing, on purpose, in a real |
| - browser — see refresh_test.go's header. */}} |
| - {{with .Drop}} |
| - <div rst-tray hidden role="region" aria-labelledby="tray-title"> |
| - <div rst-tray-head> |
| - <span rst-tray-title id="tray-title">{{.TrayTitle}}</span> |
| - <button type="button" rst-btn="quiet compact" rst-tray-dismiss>{{.TrayDismiss}}</button> |
| - </div> |
| - <ul rst-tray-list></ul> |
| - <p rst-tray-status role="status" aria-live="polite"></p> |
| - <p rst-tray-stale hidden>{{.Stale}} <a rst-tray-reload href="/">{{.Reload}}</a></p> |
| - </div> |
| - {{end}} |
| -</section> |
| +{{/* Adding content lives at /add now. The one message that still lands |
| + on this page is a failed delete, which redirects here. */}} |
| +{{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}} |
| <section rst-section rst-refresh="documents"> |
| <h2>{{len .Documents}} document{{if ne (len .Documents) 1}}s{{end}}</h2> |
| + <p><a rst-btn="primary" href="/add">Add content</a></p> |
| {{if not .Documents}} |
| <p rst-cell-mut>Nothing stored yet.</p> |
| {{else}} |
diff --git a/internal/memoriatest/app_test.go b/internal/memoriatest/app_test.go| index 283606d..c28b0b4 100644 |
| --- a/internal/memoriatest/app_test.go |
| +++ b/internal/memoriatest/app_test.go |
| @@ -120,7 +120,7 @@ func TestEmptySubmissionIsRefusedWithCopy(t *testing.T) { |
| if rec := store(t, h, "", " "); rec.Code != http.StatusSeeOther { |
| t.Fatalf("empty store: status %d, want 303", rec.Code) |
| } |
| - if !strings.Contains(get(t, h, "/?err=empty").Body.String(), "nothing to store") { |
| + if !strings.Contains(get(t, h, "/add?err=empty").Body.String(), "nothing to store") { |
| t.Error("the empty-submission page does not say what went wrong") |
| } |
| } |
diff --git a/internal/memoriatest/drop_test.go b/internal/memoriatest/drop_test.go| index d4127a5..99b5b75 100644 |
| --- a/internal/memoriatest/drop_test.go |
| +++ b/internal/memoriatest/drop_test.go |
| @@ -130,7 +130,7 @@ func dropZoneTag(t *testing.T, body string) string { |
| // (d) writing rst-dropzone="ready" in the template → "the drop zone is |
| // pre-marked in the template" |
| func TestTheUploadFormStillWorksWithoutScript(t *testing.T) { |
| - body := get(t, newApp(t), "/").Body.String() |
| + body := get(t, newApp(t), "/add").Body.String() |
| // Isolate the form that posts to /documents. Everything below is |
| // asserted inside it, never against the whole page. |
| @@ -468,7 +468,7 @@ func TestTheDropScriptPostsOnlyToTheFormsOwnAction(t *testing.T) { |
| // each of those halves. |
| func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) { |
| h := newApp(t) |
| - cap := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-max-bytes")) |
| + cap := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-bytes")) |
| if cap < 1024 { |
| t.Fatalf("data-max-bytes is %d, which no real cap is; the tests below would prove nothing", cap) |
| } |
| @@ -477,7 +477,7 @@ func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) { |
| // tested, but a document it cannot split is not a fair sample. |
| filler := "The quarterly figure was recorded in full and then again.\n\n" |
| at := strings.Repeat(filler, cap/len(filler)+1)[:cap] |
| - if rec := upload(t, h, "at-the-cap.txt", at); rec.Header().Get("Location") != "/?stored=1" { |
| + if rec := upload(t, h, "at-the-cap.txt", at); rec.Header().Get("Location") != "/add?stored=1" { |
| t.Errorf("a file of exactly the advertised cap (%d bytes) was refused with %q; "+ |
| "the page tells the browser it will be taken", cap, rec.Header().Get("Location")) |
| } |
| @@ -487,7 +487,7 @@ func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) { |
| // the whole of what a person is told and it is the half that can |
| // silently become "stored". |
| rec := upload(t, h, "over-the-cap.txt", at+"x") |
| - if loc := rec.Header().Get("Location"); loc != "/?stored=0&refused=1" { |
| + if loc := rec.Header().Get("Location"); loc != "/add?stored=0&refused=1" { |
| t.Errorf("the page advertises a cap of %d bytes but the handler answered %q for a "+ |
| "file 1 byte over it; the browser would refuse a file this server accepts, "+ |
| "or the other way round", cap, loc) |
| @@ -524,7 +524,7 @@ func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) { |
| // stays green on purpose. |
| func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) { |
| h := newApp(t) |
| - window := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-sniff-bytes")) |
| + window := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-sniff-bytes")) |
| if window < 16 { |
| t.Fatalf("data-sniff-bytes is %d; the tests below would prove nothing", window) |
| } |
| @@ -532,7 +532,7 @@ func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) { |
| // A NUL at the last byte the window covers: refused, by name. |
| inside := strings.Repeat("a", window-1) + "\x00" + strings.Repeat("b", 64) |
| rec := upload(t, h, "inside.txt", inside) |
| - if loc := rec.Header().Get("Location"); loc != "/?stored=0&refused=1" { |
| + if loc := rec.Header().Get("Location"); loc != "/add?stored=0&refused=1" { |
| t.Errorf("a NUL at byte %d — the last byte inside the advertised %d-byte window — "+ |
| "was answered %q, want a refusal", window-1, window, loc) |
| } else if said := outcome(t, h, rec); !strings.Contains(said, "inside.txt") { |
| @@ -543,9 +543,9 @@ func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) { |
| // looks that far. If this now fails, the handler reads further than |
| // the page says it does and the browser's check has gone blind. |
| outside := strings.Repeat("a", window) + "\x00" + strings.Repeat("b", 64) |
| - if loc := upload(t, h, "outside.txt", outside).Header().Get("Location"); loc != "/?stored=1" { |
| + if loc := upload(t, h, "outside.txt", outside).Header().Get("Location"); loc != "/add?stored=1" { |
| t.Errorf("a NUL at byte %d — one past the advertised %d-byte window — was answered "+ |
| - "%q, want /?stored=1; the handler inspects more than the page tells the "+ |
| + "%q, want /add?stored=1; the handler inspects more than the page tells the "+ |
| "browser it does", window, window, loc) |
| } |
| } |
| @@ -576,7 +576,7 @@ func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) { |
| // says so from the page's side |
| func TestTheAdvertisedFileCountCapIsTheHandlersCap(t *testing.T) { |
| h := newApp(t) |
| - maxFiles := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-max-files")) |
| + maxFiles := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-files")) |
| if maxFiles < 2 || maxFiles > 200 { |
| t.Fatalf("data-max-files is %d; the drop built below would prove nothing", maxFiles) |
| } |
| @@ -628,7 +628,7 @@ func TestTheAdvertisedFileCountCapIsTheHandlersCap(t *testing.T) { |
| // fails here rather than passing quietly. |
| func TestTheAdvertisedTotalCapIsTheHandlersCap(t *testing.T) { |
| h := newApp(t) |
| - body := get(t, h, "/").Body.String() |
| + body := get(t, h, "/add").Body.String() |
| total := atoi(t, dropZoneAttr(t, body, "data-max-total-bytes")) |
| per := atoi(t, dropZoneAttr(t, body, "data-max-bytes")) |
| if total <= per { |
| @@ -675,7 +675,7 @@ func TestTheAdvertisedTotalCapIsTheHandlersCap(t *testing.T) { |
| // green, which is what sharing the sentence is for. |
| func TestTheBrowserRefusesInTheHandlersWords(t *testing.T) { |
| h := newApp(t) |
| - page := get(t, h, "/").Body.String() |
| + page := get(t, h, "/add").Body.String() |
| for _, c := range []struct { |
| attr, name, body string |
| @@ -703,7 +703,7 @@ func TestTheBrowserRefusesInTheHandlersWords(t *testing.T) { |
| // what ships is what the asset route serves. |
| func servedDropScript(t *testing.T, h http.Handler) string { |
| t.Helper() |
| - body := get(t, h, "/").Body.String() |
| + body := get(t, h, "/add").Body.String() |
| m := regexp.MustCompile(`<script[^>]+src="([^"]*drop[^"]*\.js[^"]*)"`).FindStringSubmatch(body) |
| if m == nil { |
| t.Fatalf("the page links no drop script:\n%s", body) |
| @@ -783,7 +783,7 @@ func TestTheLiveRegionExistsBeforeItHasAnythingToSay(t *testing.T) { |
| } |
| // The rendered note is still a live region at all. |
| - tag := regexp.MustCompile(`<p rst-dropzone-note[^>]*>`).FindString(get(t, h, "/").Body.String()) |
| + tag := regexp.MustCompile(`<p rst-dropzone-note[^>]*>`).FindString(get(t, h, "/add").Body.String()) |
| if !strings.Contains(tag, `aria-live="polite"`) || !strings.Contains(tag, `role="status"`) { |
| t.Errorf("the note is not a polite live region, so unhiding it early buys nothing: %s", tag) |
| } |
| @@ -830,7 +830,7 @@ func TestTheLiveRegionExistsBeforeItHasAnythingToSay(t *testing.T) { |
| // the point: one number, said everywhere from one place. |
| func TestTheRefusalsNameTheCapsTheyEnforce(t *testing.T) { |
| h := newApp(t) |
| - body := get(t, h, "/").Body.String() |
| + body := get(t, h, "/add").Body.String() |
| capBytes := atoi(t, dropZoneAttr(t, body, "data-max-bytes")) |
| totalBytes := atoi(t, dropZoneAttr(t, body, "data-max-total-bytes")) |
| @@ -945,7 +945,10 @@ func TestEveryFileInADropBecomesItsOwnDocument(t *testing.T) { |
| } |
| rec := submit(t, h, "", "", files...) |
| - titles := storedTitles(t, follow(t, h, rec).Body.String()) |
| + // The redirect lands on /add, which carries the outcome; the document |
| + // list stayed on the index when adding moved to its own page. |
| + follow(t, h, rec) |
| + titles := storedTitles(t, get(t, h, "/").Body.String()) |
| if len(titles) != len(files) { |
| t.Fatalf("%d files stored %d documents: %v", len(files), len(titles), titles) |
| } |
| @@ -999,7 +1002,10 @@ func TestAPartialDropStoresTheGoodOnesAndNamesTheBad(t *testing.T) { |
| ) |
| page := follow(t, h, rec).Body.String() |
| - titles := storedTitles(t, page) |
| + // The outcome is on /add, where the redirect lands; the list stayed |
| + // on the index when adding moved to its own page. |
| + list := get(t, h, "/").Body.String() |
| + titles := storedTitles(t, list) |
| if len(titles) != 4 { |
| t.Fatalf("4 good files among 5 stored %d documents: %v", len(titles), titles) |
| } |
| @@ -1062,7 +1068,10 @@ func TestTheStoredCountIsTheNumberActuallyStored(t *testing.T) { |
| ) |
| page := follow(t, h, rec).Body.String() |
| - rows := len(storedTitles(t, page)) |
| + // The outcome is on /add, where the redirect lands; the list stayed |
| + // on the index when adding moved to its own page. |
| + list := get(t, h, "/").Body.String() |
| + rows := len(storedTitles(t, list)) |
| said := flashText(t, page) |
| m := regexp.MustCompile(`Stored (\d+) documents?\.`).FindStringSubmatch(said) |
| @@ -1072,12 +1081,12 @@ func TestTheStoredCountIsTheNumberActuallyStored(t *testing.T) { |
| } |
| if got := atoi(t, m[1]); got != rows { |
| t.Errorf("the outcome says %d documents were stored but the list holds %d.\n %q\n %v", |
| - got, rows, said, storedTitles(t, page)) |
| + got, rows, said, storedTitles(t, list)) |
| } |
| // Three files and the paste box: four. Stated so a change to the |
| // fixture cannot quietly make the comparison above trivial. |
| if rows != 4 { |
| - t.Errorf("3 good files and a paste stored %d documents, want 4: %v", rows, storedTitles(t, page)) |
| + t.Errorf("3 good files and a paste stored %d documents, want 4: %v", rows, storedTitles(t, list)) |
| } |
| } |
| @@ -1146,8 +1155,11 @@ func TestThePasteBoxIsOneMoreDocumentAndIsNeverDiscarded(t *testing.T) { |
| file{"costs.txt", "Hosting costs fell after the migration completed."}, |
| ) |
| - page := follow(t, h, rec).Body.String() |
| - titles := storedTitles(t, page) |
| + follow(t, h, rec) |
| + // The outcome is on /add, where the redirect lands; the list stayed |
| + // on the index when adding moved to its own page. |
| + list := get(t, h, "/").Body.String() |
| + titles := storedTitles(t, list) |
| if len(titles) != 3 { |
| t.Fatalf("a paste submitted alongside 2 files stored %d documents, want 3: %v", |
| len(titles), titles) |
| @@ -1194,7 +1206,7 @@ func TestThePasteBoxIsOneMoreDocumentAndIsNeverDiscarded(t *testing.T) { |
| // document" |
| func TestTheScriptlessFormStoresEveryFileItSubmits(t *testing.T) { |
| h := newApp(t) |
| - body := get(t, h, "/").Body.String() |
| + body := get(t, h, "/add").Body.String() |
| i := strings.Index(body, `action="/documents"`) |
| if i < 0 { |
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go| index bcf1481..15d1c80 100644 |
| --- a/internal/memoriatest/gates_test.go |
| +++ b/internal/memoriatest/gates_test.go |
| @@ -52,8 +52,8 @@ func TestPlaintextNeverReachesTheDatabase(t *testing.T) { |
| if rec.Code != 303 { |
| t.Fatalf("store: status %d, want 303", rec.Code) |
| } |
| - if loc := rec.Header().Get("Location"); loc != "/?stored=1" { |
| - t.Fatalf("store redirected to %q, want /?stored=1 — the document was not stored, "+ |
| + if loc := rec.Header().Get("Location"); loc != "/add?stored=1" { |
| + t.Fatalf("store redirected to %q, want /add?stored=1 — the document was not stored, "+ |
| "so the scan below would pass vacuously", loc) |
| } |
| if got := get(t, h, "/?q=zebrafinch").Body.String(); !strings.Contains(got, "<mark>") { |
| @@ -123,7 +123,7 @@ func TestAMultiFileDropNeverSpillsPlaintextToATempFile(t *testing.T) { |
| // The vacuity guard. A submission that was refused would leave the |
| // directory empty and this gate would pass having watched nothing. |
| - if loc := rec.Header().Get("Location"); loc != "/?stored=12" { |
| + if loc := rec.Header().Get("Location"); loc != "/add?stored=12" { |
| t.Fatalf("the drop answered %q rather than storing all 12; the scan below would "+ |
| "prove nothing", loc) |
| } |
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go| index d7ac622..5548133 100644 |
| --- a/internal/memoriatest/routes_test.go |
| +++ b/internal/memoriatest/routes_test.go |
| @@ -263,6 +263,8 @@ var declaredChiRoutes = map[string]bool{ |
| "GET /auth/finish": true, |
| "GET /auth/local/complete": true, |
| "GET /": true, |
| + // Adding content, moved off the index to its own page 2026-09-29. |
| + "GET /add": true, |
| // GET /search renders the results region alone, from the same |
| // template GET /?q= renders it inside a page, so live search can |
| // replace that region on a keystroke. Same session gate, no passage |
diff --git a/internal/memoriatest/tray_test.go b/internal/memoriatest/tray_test.go| index ca27b1e..cded210 100644 |
| --- a/internal/memoriatest/tray_test.go |
| +++ b/internal/memoriatest/tray_test.go |
| @@ -216,7 +216,7 @@ func TestTheJSONPathSetsNoFlashCookie(t *testing.T) { |
| // tooManyFiles builds a drop one file over the cap the page advertises. |
| func tooManyFiles(t *testing.T, h http.Handler) []file { |
| t.Helper() |
| - n := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-max-files")) |
| + n := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-files")) |
| if n < 2 || n > 200 { |
| t.Fatalf("data-max-files is %d; the drop built here would prove nothing", n) |
| } |
| @@ -260,7 +260,7 @@ func tooManyFiles(t *testing.T, h http.Handler) []file { |
| // inside the sniff window was answered stored=1" and "1 documents |
| // exist after a binary upload on the JSON path" |
| func TestTheJSONPathStillEnforcesEveryServerCheck(t *testing.T) { |
| - page := get(t, newApp(t), "/").Body.String() |
| + page := get(t, newApp(t), "/add").Body.String() |
| capBytes := atoi(t, dropZoneAttr(t, page, "data-max-bytes")) |
| window := atoi(t, dropZoneAttr(t, page, "data-sniff-bytes")) |
| if capBytes < 1024 || window < 16 { |
| @@ -315,7 +315,7 @@ func TestTheJSONPathStillEnforcesEveryServerCheck(t *testing.T) { |
| t.Run("the total-size cap", func(t *testing.T) { |
| h := newApp(t) |
| - total := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-max-total-bytes")) |
| + total := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-total-bytes")) |
| if total <= capBytes { |
| t.Fatalf("the advertised total (%d) is not larger than the per-file cap (%d)", total, capBytes) |
| } |
| @@ -369,7 +369,7 @@ func TestTheJSONPathStillEnforcesEveryServerCheck(t *testing.T) { |
| // scan that stripped comments first would risk skipping a real string. |
| func TestEveryWordTheTrayWritesComesFromGo(t *testing.T) { |
| h := newApp(t) |
| - tag := dropZoneTag(t, get(t, h, "/").Body.String()) |
| + tag := dropZoneTag(t, get(t, h, "/add").Body.String()) |
| src := servedDropScript(t, h) |
| pairs := regexp.MustCompile(`\bdata-(msg|reason)-([a-z-]+)="([^"]*)"`).FindAllStringSubmatch(tag, -1) |
| @@ -471,7 +471,7 @@ func TestTheTrayIsALiveRegionThatExistsBeforeItSpeaks(t *testing.T) { |
| } |
| } |
| - body := get(t, h, "/").Body.String() |
| + body := get(t, h, "/add").Body.String() |
| stat := regexp.MustCompile(`<p rst-tray-status[^>]*>`).FindString(body) |
| if stat == "" { |
| t.Fatalf("no tray status element on the page:\n%s", body) |
| @@ -525,7 +525,7 @@ func TestTheTrayIsALiveRegionThatExistsBeforeItSpeaks(t *testing.T) { |
| // (d) writing rst-tray="open" in the template → "the tray is |
| // pre-marked open in the template" |
| func TestTheTrayIsOutsideTheFormAndAbsentWithoutScript(t *testing.T) { |
| - body := get(t, newApp(t), "/").Body.String() |
| + body := get(t, newApp(t), "/add").Body.String() |
| i := strings.Index(body, `action="/documents"`) |
| if i < 0 { |