oficina / memoria Public

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>
Paul Campbell pushed by paul@keymail.dev 9d03e6ebb5cdce234ccc0df444995309871ed964 parent 7d3fa70
11 files changed, +209 −163
  • internal/memoria/app.go +1 −0
  • internal/memoria/handlers.go +22 −5
  • internal/memoria/lag_test.go +1 −1
  • internal/memoria/render.go +1 −1
  • internal/memoria/templates/add.html +134 −0
  • internal/memoria/templates/index.html +4 −124
  • internal/memoriatest/app_test.go +1 −1
  • internal/memoriatest/drop_test.go +34 −22
  • internal/memoriatest/gates_test.go +3 −3
  • internal/memoriatest/routes_test.go +2 −0
  • internal/memoriatest/tray_test.go +6 −6

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	3.193s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	0.005s
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.520s
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	0.036s
ok  	amadan.net/oficina/memoria/internal/memoria/notion	0.020s
ok  	amadan.net/oficina/memoria/internal/memoria/provider	1.129s
ok  	amadan.net/oficina/memoria/internal/memoria/query	0.004s
ok  	amadan.net/oficina/memoria/internal/memoria/seal	0.004s
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	3.020s
ok  	amadan.net/oficina/memoria/internal/memoriatest	1.544s
?   	amadan.net/oficina/memoria/scripts/writeestilo	[no test files]
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/memoria/app.go b/internal/memoria/app.go
index 30cad65..1b2eadf 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 fb5a43b..942f707 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", "connectors", "notion", "home", "credits"} {
+ for _, name := range []string{"index", "add", "document", "signin", "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 {