oficina / memoria Public

feat: drag and drop a file onto the page, scriptless path unchanged

Dropping a file anywhere except exactly onto the file input used to make
the browser navigate away and lose the page. Now a drop anywhere on the
document puts the file into the form's own input via DataTransfer, and
the existing multipart POST does the rest. No fetch, no XHR, no second
endpoint.

Progressive enhancement, per AGENTS.md's "every affordance works
scriptless": the input is wrapped, never replaced, and drop.js registers
nothing until it has proved DataTransfer, FileReader and its data
attributes all exist. A page whose script did not load shows exactly what
it showed before — verified with script execution disabled, not assumed.

Several files dropped at once are REFUSED WHOLE and counted back to the
reader, rather than storing the first and discarding the rest. Keeping one
of five is the silent truncation this codebase treats as its worst
failure. Same for a folder, an empty file, an over-cap file, a NUL-bearing
file, and the case where assigning to input.files silently no-ops — that
assignment is read back and the page says so if the file is not there.

The cap and the NUL-sniff window are published to the browser as data
attributes so it refuses in the handler's own words, and tests fail if the
two halves ever disagree. The server still enforces both independently;
nothing client-side is load-bearing.

Fixes a pre-existing bug found on the way: [rst-result-trail] read
var(--rst-text-mut), which nothing defines. An undefined custom property
invalidates the whole declaration at computed-value time, so the rule
vanished and the heading trail was muted in the markup and not on screen.
Gate TestEveryTokenReferencedHereIsDefinedOrHasAFallback now guards the
class rather than the instance, and deliberately passes --rst-font-mono,
which is undefined but has a fallback.

Reviewed by Claude Fable 5.1, which did not write it: REQUEST CHANGES, 3
required. All three were real and all three are fixed here.

- A comment named two tests that do not exist. That is the failure
  AGENTS.md records verbatim, and it is how a deleted guard stays
  invisible. Every test name, symbol and route named anywhere in this
  change is now checked to resolve.
- The no-submit guard forbade ".submit(" and not "requestSubmit(". The
  reviewer swapped one for the other and the suite stayed green. It is in
  the list now, and the mutation is recorded as having been green before.
- The aria-live note was unhidden in the same tick its text was written,
  which screen readers commonly do not announce — so a blind user who
  dropped five files got silence. A refusal inaudible to the person being
  refused is the same defect as a silent truncation. The empty note now
  enters the tree at the ready step.

Every test here was watched failing before it was trusted; each says what
was mutated. Two guards that could NOT fail were found that way and are
noted in place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJRwhRTr2kBeBjQNuH4HWm
Paul Campbell pushed by paul@keymail.dev 4e0bee4e642a8b97cfed8e4ede9dcdd7972b9e25 parent 1b68ced
8 files changed, +1150 −7
  • internal/memoria/handlers.go +87 −3
  • internal/memoria/static/drop.js +284 −0
  • internal/memoria/static/memoria.css +51 −2
  • internal/memoria/templates/index.html +42 −1
  • internal/memoria/templates/layout.html +5 −0
  • internal/memoriatest/drop_test.go +576 −0
  • internal/memoriatest/gates_test.go +91 −0
  • tools/drive/main.go +14 −1
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 54758ea..b323588 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -50,6 +50,75 @@ type page struct {
FlashKind string
Results []Result
Documents []Summary
+ Drop dropZone
+}
+
+// dropZone is everything static/drop.js needs in order to refuse a
+// dropped file the way this handler would. It is rendered onto the drop
+// zone as data attributes and read from there, so the numbers and the
+// words exist once.
+//
+// Written down rather than duplicated in JavaScript because a client
+// check that has drifted from the server is worse than no client check:
+// it refuses what the server would have taken, or promises to take what
+// the server will refuse, and either way the page is lying about the
+// rule. The two numbers are the handler's own constants, and
+// TestTheAdvertisedCapIsTheHandlersCap and
+// TestTheAdvertisedSniffWindowIsTheHandlersWindow post files either side
+// of each rendered boundary to prove the handler still agrees with what
+// the page advertises.
+//
+// The browser's checks are a courtesy and never the guard. Every one of
+// them is made again in createDocument, on bytes rather than on a File
+// object, because a drop is not the only way to reach that handler.
+type dropZone struct {
+ MaxBytes int
+ SniffBytes int
+ Limit string
+ TooBig string
+ Binary string
+ Empty string
+ Many string
+ Folder string
+ Blocked string
+}
+
+// newDropZone builds that. TooBig and Binary are flashFor's own strings:
+// a refusal made in the browser and the same refusal made by the handler
+// are then the same sentence, which is the point — a person who drops a
+// PDF and a person who picks one from the file chooser are told the same
+// thing.
+func newDropZone() dropZone {
+ return dropZone{
+ MaxBytes: maxUpload,
+ SniffBytes: sniffBytes,
+ // Derived, so the hint cannot come to disagree with the cap it
+ // describes — nor with the refusal, which says it in the same
+ // words from the same place.
+ Limit: uploadLimit(),
+ TooBig: flashFor("toobig"),
+ Binary: flashFor("binary"),
+ // Not flashFor("empty"): that sentence is about the form as a
+ // whole ("the box was empty and no file was chosen"), and here
+ // a file was very much chosen.
+ Empty: "That file is empty, so there was nothing to store.",
+ // "{n}" is filled in by the browser, which is the only place
+ // that knows the count.
+ //
+ // Note what this does NOT say: not "only the first was taken".
+ // The handler stores one document per submission and the input
+ // is deliberately not `multiple`, so a drop of five files that
+ // quietly kept one would be exactly the silent truncation this
+ // codebase treats as its worst failure — a person would leave
+ // believing five documents were stored. Nothing is taken, and
+ // the count is said out loud.
+ Many: "That drop had {n} files. memoria stores one document at a time, so none of them were taken — drop them one after another.",
+ Folder: "That is a folder, and memoria stores one text file at a time. Drop a single file instead.",
+ // The browsers that refuse this are old, but the failure is
+ // silent — the file simply never arrives in the input — and a
+ // silent nothing is the one outcome that must not happen.
+ Blocked: "This browser would not let a dropped file into the form, so nothing was taken. Choose the file above instead — it stores exactly the same way.",
+ }
}
// chunkCount returns the indexed passage count, and -1 when it cannot be
@@ -75,6 +144,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
Chunks: a.chunkCount(),
Query: q,
Searched: q != "",
+ Drop: newDropZone(),
}
if p.Searched {
results, err := a.store.Search(r.Context(), q, 25)
@@ -105,6 +175,14 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
render(w, "index", p)
}
+// uploadLimit is maxUpload said out loud, and it is the ONLY place the
+// cap is put into words. flashFor's refusal and the drop zone's hint
+// both read it, because a refusal that names a different number from the
+// hint above it is a page arguing with itself and neither line is
+// obviously the wrong one. TestTheRefusalNamesTheCapItEnforces posts a
+// file over the cap and reads the number back off the refusal.
+func uploadLimit() string { return strconv.Itoa(maxUpload>>20) + " MiB" }
+
// flashFor maps an error code to copy. Codes travel in the URL rather
// than messages, so nothing a person typed is reflected back into the
// page or into a log line.
@@ -113,7 +191,7 @@ func flashFor(code string) string {
case "empty":
return "There was nothing to store — the box was empty and no file was chosen."
case "toobig":
- return "That is larger than 2 MiB. Split it, or wait for background ingestion."
+ return "That is larger than " + uploadLimit() + ". Split it, or wait for background ingestion."
case "binary":
return "That looked like a binary file. memoria indexes text: .txt, .md, .csv."
default:
@@ -282,14 +360,20 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
render(w, "signin", p)
}
+// sniffBytes is how much of an upload looksLikeText inspects. Named
+// because the drop zone inspects exactly the same prefix in the browser,
+// and it reads the number off the page rather than carrying its own copy
+// — see dropZone.
+const sniffBytes = 8192
+
// looksLikeText refuses a binary upload. A NUL byte in the first 8 KiB is
// the cheap, reliable signal; a PDF or DOCX would otherwise be chunked as
// mojibake and indexed as though it were prose, which produces citations
// nobody can read.
func looksLikeText(b []byte) bool {
n := len(b)
- if n > 8192 {
- n = 8192
+ if n > sniffBytes {
+ n = sniffBytes
}
for _, c := range b[:n] {
if c == 0 {
diff --git a/internal/memoria/static/drop.js b/internal/memoria/static/drop.js
new file mode 100644
index 0000000..e8a6f23
--- /dev/null
+++ b/internal/memoria/static/drop.js
@@ -0,0 +1,284 @@
+/* drop.js — drag a text file onto the page and it lands in the upload
+ form. Enhancement, and nothing more.
+
+ Three rules it is written to keep, in this order:
+
+ 1. It is additive. The file input and the Store it button below it are
+ untouched; this file only ever puts a File into that input. With
+ scripts off, with this file blocked, or in a browser missing any
+ piece it needs, the zone never says "ready", nothing is styled, and
+ the form is exactly the form it was before. There is no second
+ upload path — no request of its own, no endpoint of its own, and
+ it never submits the form for you. The person still presses Store
+ it, and the ordinary multipart POST does the rest — so everything
+ the server checks, it still checks.
+
+ 2. It never does less than it was asked and calls it done. A drop of
+ five files is refused whole, out loud, with the count — not
+ quietly reduced to one, which is the failure this codebase hates
+ most: five files in, one document stored, nobody told. The same
+ goes for the case where assigning to input.files does nothing
+ (older Safari): the assignment is read back, and if the file is
+ not there the page says so instead of sitting there looking ready.
+
+ 3. Its rules are the server's rules. The size cap, the sniff window
+ and the words for both come off the page as data attributes, put
+ there by handlers.go. Nothing is written down twice, so a browser
+ refusal and a handler refusal cannot drift apart. Missing or
+ unreadable attributes are a hard stop, not a default: a guessed
+ cap would refuse files the server would have taken.
+
+ What it deliberately does not do: submit for you (a file you dropped
+ by accident is then unrecoverable), read anything but the first
+ sniff-window bytes, or name the file anywhere but on your own screen.
+ There is no console output in this file at all — a filename is
+ somebody's content and this codebase keeps content out of logs. */
+(function () {
+ "use strict";
+
+ var zone = document.querySelector("[rst-dropzone]");
+ if (!zone) return;
+
+ var input = zone.querySelector('input[type="file"]');
+ var note = zone.querySelector("[rst-dropzone-note]");
+ var hint = zone.querySelector("[rst-dropzone-hint]");
+ var paste = document.getElementById("body");
+ if (!input || !note || !hint) return;
+
+ // Everything this needs, checked before anything is styled. A drop
+ // target that cannot put a file in the input is a trap, so if any of
+ // this is missing the visitor gets the plain input instead and never
+ // learns there was going to be a drop zone.
+ if (typeof DataTransfer === "undefined") return;
+ if (typeof FileReader === "undefined") return;
+
+ var maxBytes = number("data-max-bytes");
+ var sniffBytes = number("data-sniff-bytes");
+ if (maxBytes === null || sniffBytes === null) return;
+
+ var msg = {};
+ var keys = ["toobig", "binary", "empty", "many", "folder", "blocked"];
+ for (var i = 0; i < keys.length; i++) {
+ var text = zone.getAttribute("data-msg-" + keys[i]);
+ if (!text) return; // a refusal with no words is a silent refusal
+ msg[keys[i]] = text;
+ }
+
+ function number(attr) {
+ var raw = zone.getAttribute(attr);
+ if (!raw) return null;
+ var n = parseInt(raw, 10);
+ if (!isFinite(n) || n <= 0) return null;
+ return n;
+ }
+
+ // ---- saying things -------------------------------------------------
+
+ // say puts one sentence in the live region under the input. tone is
+ // "good" or "danger"; both resolve to tokens in memoria.css.
+ //
+ // It does NOT unhide the note, and that omission is the point: the
+ // paragraph is unhidden empty at the ready step below, so by the time
+ // anything is written here the live region has been in the
+ // accessibility tree for a while. Screen readers commonly announce
+ // nothing when a region appears and gains its text in the same tick,
+ // and this region's whole job is to carry refusals — a refusal a blind
+ // person cannot hear is the silent failure this codebase exists to
+ // avoid. memoria.css keeps the empty paragraph from taking any space.
+ function say(text, tone) {
+ note.textContent = text;
+ note.setAttribute("rst-dropzone-note", tone);
+ }
+
+ function mark(tokens) {
+ zone.setAttribute("rst-dropzone", tokens);
+ }
+
+ // ---- the drag ------------------------------------------------------
+
+ // A drag of files, as opposed to a drag of text. Text dragged into the
+ // textarea or the search box is the browser's business and is left
+ // alone; only file drags are intercepted.
+ function hasFiles(e) {
+ var dt = e.dataTransfer;
+ if (!dt || !dt.types) return false;
+ for (var i = 0; i < dt.types.length; i++) {
+ if (dt.types[i] === "Files") return true;
+ }
+ return false;
+ }
+
+ // dragenter and dragleave fire once per element crossed, so a plain
+ // boolean flickers off every time the pointer passes a border. Count
+ // instead.
+ var depth = 0;
+
+ function over(on) {
+ if (!on) depth = 0;
+ mark(on ? "ready over" : "ready");
+ }
+
+ document.addEventListener("dragenter", function (e) {
+ if (!hasFiles(e)) return;
+ depth++;
+ over(true);
+ });
+
+ document.addEventListener("dragover", function (e) {
+ if (!hasFiles(e)) return;
+ // This preventDefault is the whole reason a drop anywhere on the
+ // page is safe: without it the browser navigates to the dropped
+ // file and the half-filled form is gone.
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
+ });
+
+ document.addEventListener("dragleave", function (e) {
+ if (!hasFiles(e)) return;
+ depth--;
+ if (depth <= 0) over(false);
+ });
+
+ document.addEventListener("drop", function (e) {
+ if (!hasFiles(e)) return;
+ e.preventDefault();
+ over(false);
+ take(e.dataTransfer);
+ });
+
+ // ---- the drop ------------------------------------------------------
+
+ function take(dt) {
+ // webkitGetAsEntry has to be called now, synchronously: the item
+ // list is emptied the moment this handler returns.
+ var folder = looksLikeFolder(dt);
+
+ var files = dt.files;
+ if (!files || files.length === 0) {
+ if (folder) {
+ say(msg.folder, "danger");
+ return;
+ }
+ // A file drag that arrived with no file. Nobody should have to
+ // wonder whether it worked.
+ say(msg.blocked, "danger");
+ return;
+ }
+ if (files.length > 1) {
+ say(msg.many.replace("{n}", String(files.length)), "danger");
+ return;
+ }
+ if (folder) {
+ say(msg.folder, "danger");
+ return;
+ }
+
+ var file = files[0];
+ if (file.size > maxBytes) {
+ say(msg.toobig, "danger");
+ return;
+ }
+ if (file.size === 0) {
+ say(msg.empty, "danger");
+ return;
+ }
+ sniff(file, function (binary) {
+ if (binary === null) {
+ // Could not read it. Not "it is fine" — the server may still
+ // take it, but this page will not claim to know either way.
+ say(msg.blocked, "danger");
+ return;
+ }
+ if (binary) {
+ say(msg.binary, "danger");
+ return;
+ }
+ hand(file);
+ });
+ }
+
+ // A dropped directory arrives as a single File with no useful
+ // contents. The entry API is the only reliable way to tell.
+ function looksLikeFolder(dt) {
+ var items = dt.items;
+ if (!items) return false;
+ for (var i = 0; i < items.length; i++) {
+ var item = items[i];
+ if (!item || item.kind !== "file" || !item.webkitGetAsEntry) continue;
+ var entry = item.webkitGetAsEntry();
+ if (entry && entry.isDirectory) return true;
+ }
+ return false;
+ }
+
+ // sniff reads the same prefix looksLikeText reads and looks for the
+ // same NUL. done(true) binary, done(false) text, done(null) unknown.
+ function sniff(file, done) {
+ var reader = new FileReader();
+ reader.onerror = function () {
+ done(null);
+ };
+ reader.onload = function () {
+ var bytes;
+ try {
+ bytes = new Uint8Array(reader.result);
+ } catch (err) {
+ done(null);
+ return;
+ }
+ for (var i = 0; i < bytes.length; i++) {
+ if (bytes[i] === 0) {
+ done(true);
+ return;
+ }
+ }
+ done(false);
+ };
+ try {
+ reader.readAsArrayBuffer(file.slice(0, sniffBytes));
+ } catch (err) {
+ done(null);
+ }
+ }
+
+ // hand puts the file in the form's own input — and then checks that it
+ // is actually there. Assigning input.files is a no-op in some
+ // browsers, and it fails silently: the zone would sit there saying
+ // "ready to store" over an empty input.
+ function hand(file) {
+ try {
+ var dt = new DataTransfer();
+ dt.items.add(file);
+ input.files = dt.files;
+ } catch (err) {
+ say(msg.blocked, "danger");
+ return;
+ }
+ if (input.files.length !== 1 ||
+ input.files[0].name !== file.name ||
+ input.files[0].size !== file.size) {
+ say(msg.blocked, "danger");
+ return;
+ }
+ // Anything watching the input — the browser's own validation, a
+ // future enhancement — sees a real change.
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+
+ var text = "Ready to store: " + file.name + ". Press Store it.";
+ if (paste && paste.value.trim() !== "") {
+ // The handler lets a file override the pasted text, so saying
+ // nothing here would lose someone's paste without a word.
+ text += " The pasted text above will not be stored — a file replaces it.";
+ }
+ say(text, "good");
+ }
+
+ // ---- and only now does the zone exist ------------------------------
+
+ // The empty live region goes into the accessibility tree first and
+ // stays there. See say(): this is the only place the note is unhidden,
+ // and TestTheLiveRegionExistsBeforeItHasAnythingToSay holds it here.
+ note.hidden = false;
+ mark("ready");
+ hint.hidden = false;
+})();
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css
index 6bcbdac..157f630 100644
--- a/internal/memoria/static/memoria.css
+++ b/internal/memoria/static/memoria.css
@@ -3,7 +3,7 @@
* Every colour is a token. Not one literal appears here, so the
* reader's light/dark choice resolves through theme.css and a passage
* never paints against the wrong ground. Sheets enforces the same rule
- * with a test; memoria's equivalent is TestNoColourLiterals.
+ * with a test; memoria's equivalent is TestNoColourLiteralsInAppCSS.
*/
[rst-page] { margin: 0 auto; max-width: 46rem; padding: 1.5rem 1rem 4rem; }
@@ -64,12 +64,61 @@
}
[rst-stack] textarea { font-family: var(--rst-font-mono, ui-monospace, monospace); resize: vertical; }
+/* The drop zone. Nothing here paints until static/drop.js has proved it
+ * can put a file in the input and written rst-dropzone="ready" — so a
+ * page whose script did not load shows an ordinary file input inside an
+ * invisible wrapper, rather than a dashed box that swallows drops. The
+ * bare [rst-dropzone] selector is deliberately styled with nothing.
+ */
+[rst-dropzone~="ready"] {
+ border: 1px dashed var(--rst-line-strong, var(--rst-line));
+ border-radius: var(--rst-radius-sm, 6px);
+ padding: 0.7rem 0.75rem;
+ transition: background-color 0.12s ease, border-color 0.12s ease;
+}
+/* A file is over the page. The whole document is the drop target — the
+ * zone is where it lands, not where it must be aimed — so this has to be
+ * visible from wherever the pointer happens to be.
+ */
+[rst-dropzone~="over"] {
+ background: var(--rst-accent-soft, var(--rst-surface-2));
+ border-color: var(--rst-accent, var(--rst-line-strong));
+ border-style: solid;
+}
+[rst-dropzone-hint], [rst-dropzone-note] { font-size: 0.8125rem; margin: 0.5rem 0 0; }
+/* Muted, and not with .rst-cell-mut: that utility exists only as a class,
+ * so `rst-cell-mut` written as an attribute selects nothing — and the
+ * class itself is a table-cell rule (nowrap, ellipsis) that would clip
+ * this sentence at the edge of the zone.
+ */
+[rst-dropzone-hint] { color: var(--rst-text-muted); }
+/* drop.js unhides the note while it is still empty, so the live region is
+ * in the accessibility tree before it ever has to announce a refusal. An
+ * empty paragraph with a top margin would push the Store it button down
+ * by half a line for no reason anyone could see, so while it is empty it
+ * takes no space at all.
+ */
+[rst-dropzone-note]:empty { margin: 0; }
+[rst-dropzone-note~="good"] { color: var(--rst-tone-positive-fg); }
+[rst-dropzone-note~="danger"] { color: var(--rst-tone-negative-fg); }
+@media (prefers-reduced-motion: reduce) {
+ [rst-dropzone~="ready"] { transition: none; }
+}
+
[rst-results] { list-style: none; margin: 0; padding: 0; }
[rst-result] { border-top: 1px solid var(--rst-line); padding: 0.9rem 0; }
[rst-result]:first-child { border-top: 0; }
[rst-result-title] { font-weight: 600; }
[rst-result-trail] {
- color: var(--rst-text-mut);
+ /* The token is --rst-text-muted. This said --rst-text-mut, which
+ * theme.css does not define and which had no fallback, so the whole
+ * declaration was invalid at computed-value time — not "grey went
+ * wrong", but the colour rule silently absent, leaving the trail in
+ * ordinary body text: muted in the markup, not muted on the screen.
+ * TestEveryTokenReferencedHereIsDefinedOrHasAFallback now catches the
+ * whole class of that mistake.
+ */
+ color: var(--rst-text-muted);
display: block;
font-size: 0.8125rem;
margin-top: 0.1rem;
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html
index 699173b..32c25d3 100644
--- a/internal/memoria/templates/index.html
+++ b/internal/memoria/templates/index.html
@@ -42,7 +42,48 @@
<label for="body">Paste text</label>
<textarea id="body" name="body" rows="8" placeholder="Paste a document, notes, minutes…"></textarea>
<label for="file">…or upload a text file</label>
- <input type="file" id="file" name="file" accept=".txt,.md,.markdown,.csv,text/plain,text/markdown">
+ {{/* 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 a file, 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.
+
+ 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
+ stays single-file: the handler stores one document per
+ submission, so `multiple` would let the file chooser hand it
+ five and have four vanish without a word. */}}
+ {{with .Drop}}
+ <div rst-dropzone
+ data-max-bytes="{{.MaxBytes}}"
+ data-sniff-bytes="{{.SniffBytes}}"
+ data-msg-toobig="{{.TooBig}}"
+ data-msg-binary="{{.Binary}}"
+ data-msg-empty="{{.Empty}}"
+ data-msg-many="{{.Many}}"
+ data-msg-folder="{{.Folder}}"
+ data-msg-blocked="{{.Blocked}}">
+ <input type="file" id="file" name="file" 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>…or drop one anywhere on this page. One file at a time, up to {{.Limit}}.</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="{{.FlashKind}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}}
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html
index 459d8fa..11d50bf 100644
--- a/internal/memoria/templates/layout.html
+++ b/internal/memoria/templates/layout.html
@@ -14,6 +14,11 @@
<link rel="stylesheet" href="{{asset "static/theme.css"}}">
<link rel="stylesheet" href="{{asset "static/memoria.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
+{{/* drop.js is inert on every page but the one carrying [rst-dropzone]:
+ it looks for the zone first and returns if there is none. It is
+ linked here rather than in index.html because the layout is where
+ scripts are linked, and it costs a querySelector to load it twice. */}}
+<script defer src="{{asset "static/drop.js"}}"></script>
{{iconAssets}}
</head>
<body>
diff --git a/internal/memoriatest/drop_test.go b/internal/memoriatest/drop_test.go
new file mode 100644
index 0000000..310eb3e
--- /dev/null
+++ b/internal/memoriatest/drop_test.go
@@ -0,0 +1,576 @@
+package memoriatest
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// The tests in this file cover drag-and-drop upload. Read this before
+// adding to them, because half of the feature is JavaScript and Go
+// cannot run it:
+//
+// What these tests DO prove is the half that decides whether the feature
+// is safe — that the scriptless form is untouched, that the script is
+// actually served and has no upload path of its own, and that the limits
+// and the words the browser refuses with are the handler's own, checked
+// by posting files either side of each advertised boundary.
+//
+// What they CANNOT prove is that the drop handler does what its copy
+// says. That was verified in a real browser instead, and here is the
+// recipe, because a verification nobody can repeat is a claim:
+//
+// go run ./tools/drive # serves on :8824, no sign-in
+// chromium --headless --remote-debugging-port=9222 about:blank
+//
+// (drive puts its throwaway database under the system temp directory.
+// -dir moves it, but MkdirTemp does not create the parent, so whatever
+// you pass has to exist already — an earlier version of this recipe said
+// `-dir /tmp/x` and panicked on any machine without a /tmp/x.)
+//
+// then over CDP, with the page open, send Input.dispatchDragEvent —
+// dragEnter, dragOver, drop — with real paths in data.files. Use that
+// rather than a synthetic DragEvent: it goes through the browser's own
+// drag machinery, so it is the only way to see that the page does not
+// navigate away, which is what a dropped file used to do. Then read
+// location.href, input.files.length and [rst-dropzone-note].
+//
+// Checked that way on 2026-09-04, Chromium 151: one file dropped on the
+// textarea and on the document list both landed in the input with the
+// page still on "/" and the textarea untouched; two files at once left
+// input.files empty and said "That drop had 2 files"; a folder was
+// named as a folder; and pressing Store it after a drop stored the
+// document, which then came back from a search.
+
+// dropZoneAttr pulls one attribute off the drop zone element.
+//
+// It matches inside the zone's own tag rather than anywhere in the page,
+// which matters more than it looks: the front page renders the search
+// results and the whole document list too, so "the string appears in the
+// HTML" is a false pass waiting to happen.
+func dropZoneAttr(t *testing.T, body, attr string) string {
+ t.Helper()
+ tag := dropZoneTag(t, body)
+ m := regexp.MustCompile(`\b` + regexp.QuoteMeta(attr) + `="([^"]*)"`).FindStringSubmatch(tag)
+ if m == nil {
+ t.Fatalf("the drop zone carries no %s:\n%s", attr, tag)
+ }
+ return m[1]
+}
+
+// dropZoneTag returns the opening tag of the drop zone.
+func dropZoneTag(t *testing.T, body string) string {
+ t.Helper()
+ i := strings.Index(body, "<div rst-dropzone")
+ if i < 0 {
+ t.Fatalf("no drop zone on the page:\n%s", body)
+ }
+ j := strings.Index(body[i:], ">")
+ if j < 0 {
+ t.Fatal("the drop zone tag never closes")
+ }
+ return body[i : i+j+1]
+}
+
+// The whole feature is an enhancement, so the form underneath it has to
+// be the form it was. Three things make that true and each is checked:
+// the file input still exists inside the drop zone (wrapped, not
+// replaced), it is still single-file, and nothing the script controls is
+// visible before the script has run.
+//
+// `multiple` is the one to watch. Adding it looks harmless and is not:
+// createDocument reads r.FormFile("file"), which takes the first part
+// and drops the rest without a word, so a file chooser that let someone
+// pick five would store one and say "Stored, chunked and indexed."
+//
+// MUTATION-TESTED 2026-09-04, in index.html, each reverted:
+//
+// (a) deleting the <input type="file"> → "the upload form has no file
+// input; the drop zone replaced it instead of wrapping it"
+// (a2) moving that input above `{{with .Drop}}`, so it is on the page
+// but outside the zone → "the file input is outside the drop zone"
+// (b) adding `multiple` to it → "the file input is `multiple`, but
+// createDocument stores one document per submission"
+// (c) deleting `hidden` from the hint → "rst-dropzone-hint is visible
+// before any script has run"
+// (c2) replacing `hidden` on the hint with `data-was-hidden="yes"` →
+// the same failure. Recorded because it did NOT fail before: the
+// check was strings.Contains(tag, "hidden"), which that attribute
+// satisfies, so the guard passed over a hint that was visible on a
+// scriptless page. It is a boundary match now.
+// (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()
+
+ // Isolate the form that posts to /documents. Everything below is
+ // asserted inside it, never against the whole page.
+ i := strings.Index(body, `action="/documents"`)
+ if i < 0 {
+ t.Fatalf("no upload form on the page:\n%s", body)
+ }
+ j := strings.Index(body[i:], "</form>")
+ if j < 0 {
+ t.Fatal("the upload form never closes")
+ }
+ form := body[i : i+j]
+
+ if !strings.Contains(form, `<button rst-btn="primary">Store it</button>`) {
+ t.Errorf("the scriptless submit button is gone from the upload form:\n%s", form)
+ }
+
+ input := regexp.MustCompile(`<input[^>]*type="file"[^>]*>`).FindString(form)
+ if input == "" {
+ t.Fatalf("the upload form has no file input; the drop zone replaced it "+
+ "instead of wrapping it, and the form no longer works without script:\n%s", form)
+ }
+ if !strings.Contains(input, `name="file"`) {
+ t.Errorf("the file input is not named `file`, so createDocument's FormFile(\"file\") will not see it: %s", input)
+ }
+ if regexp.MustCompile(`\bmultiple\b`).MatchString(input) {
+ t.Errorf("the file input is `multiple`, but createDocument stores one document per "+
+ "submission and takes the first part only — four of five chosen files would "+
+ "vanish silently: %s", input)
+ }
+
+ // Containment: the input must sit between the drop zone's opening
+ // tag and the end of the zone.
+ zone := strings.Index(form, "<div rst-dropzone")
+ if zone < 0 {
+ t.Fatalf("no drop zone inside the upload form:\n%s", form)
+ }
+ end := strings.Index(form[zone:], "</div>")
+ if end < 0 {
+ t.Fatal("the drop zone never closes")
+ }
+ if !strings.Contains(form[zone:zone+end], input) {
+ t.Error("the file input is outside the drop zone; dropping onto the zone would " +
+ "have nowhere to put the file")
+ }
+
+ // Nothing the script drives may show before the script has run: a
+ // hint promising a drop target, on a page with no drop target, is a
+ // promise the page cannot keep.
+ // `hidden` is matched at its boundaries, not as a substring: the
+ // substring form was satisfied by any attribute with those six
+ // letters anywhere in it — data-was-hidden="yes" passed — which is
+ // the same shape of false pass as "result" matching "0 results".
+ hiddenAttr := regexp.MustCompile(`(^|\s)hidden(\s|>|=)`)
+ for _, el := range []string{"rst-dropzone-hint", "rst-dropzone-note"} {
+ tag := regexp.MustCompile(`<p ` + el + `[^>]*>`).FindString(form)
+ if tag == "" {
+ t.Errorf("no %s element in the form:\n%s", el, form)
+ continue
+ }
+ if !hiddenAttr.MatchString(tag) {
+ t.Errorf("%s is visible before any script has run, so a scriptless page "+
+ "promises a drop zone it does not have: %s", el, tag)
+ }
+ }
+ // And the zone itself is unstyled until the script marks it ready.
+ if v := dropZoneTag(t, body); strings.Contains(v, `rst-dropzone="`) {
+ t.Errorf("the drop zone is pre-marked in the template: %s\n"+
+ "The styling keys off rst-dropzone=\"ready\", which only the script may "+
+ "write — otherwise a page whose script failed draws a dashed box that "+
+ "eats files.", v)
+ }
+}
+
+// The script has to actually be served, and it has to have no upload
+// path of its own: the drop fills the form's input and the ordinary
+// multipart POST does the rest, so everything createDocument checks is
+// still checked. A fetch() in here would be a second, unchecked way in.
+//
+// Note for whoever edits drop.js next: this scans the file whole,
+// comments included, so prose in that file may not spell the forbidden
+// names either. That is the deliberate direction of error — a comment
+// that trips this fails loudly, where stripping comments first would
+// risk over-stripping and passing something real.
+//
+// MUTATION-TESTED 2026-09-04, each reverted:
+//
+// (a) `mv static/drop.js static/drop2.js` → "GET /static/drop.js:
+// status 404, want 200"
+// (b) replacing the dispatchEvent line with `fetch("/documents", …)` →
+// "the drop script uses fetch("
+// (c) replacing it with `input.form.submit()` → "uses .submit("
+// (c2) replacing it with `input.form.requestSubmit()` → "uses
+// requestSubmit(". Recorded because this one was GREEN before:
+// `.submit(` does not match `requestSubmit(`, so the modern
+// spelling of the thing this test exists to forbid walked straight
+// through it, and drop.js could have posted a file the moment it
+// landed — no chance to undo an accidental drop.
+// (d) adding `console.log("dropped", file.name)` → "the drop script
+// writes to the console; a dropped file's name is content"
+func TestTheDropScriptIsServedAndHasNoUploadPathOfItsOwn(t *testing.T) {
+ src := servedDropScript(t, newApp(t))
+
+ // requestSubmit( is listed separately and deliberately: it is not a
+ // suffix of ".submit(" and the whole point of the rule is that the
+ // script never submits the form, by any spelling.
+ for _, forbidden := range []string{"fetch(", "XMLHttpRequest", "navigator.sendBeacon", ".submit(", "requestSubmit("} {
+ if strings.Contains(src, forbidden) {
+ t.Errorf("the drop script uses %s. The drop must fill the form's own input and "+
+ "nothing else: a second upload path is a path the server's cap, its NUL "+
+ "check and its CSRF middleware were not asked about.", forbidden)
+ }
+ }
+ // A filename is somebody's content. It never goes to a log, and the
+ // browser console is close enough to one to keep the rule.
+ if strings.Contains(src, "console.") {
+ t.Error("the drop script writes to the console; a dropped file's name is content")
+ }
+}
+
+// The cap the page advertises to the browser is the cap the handler
+// enforces. Not "roughly": the same integer.
+//
+// This is the check that makes the client-side size refusal honest. A
+// drifted cap is worse than none — the page would refuse a file the
+// server would have taken, and the person would have no way to find that
+// out. So the number is read off the rendered page and files of exactly
+// that size and exactly one byte more are posted at the real handler.
+//
+// MUTATION-TESTED 2026-09-04, each reverted:
+//
+// (a) data-max-bytes="100000" in index.html → "the page advertises a
+// cap of 100000 bytes but the handler answered "/?stored=1" for a
+// file 1 byte over it"
+// (b) `io.LimitReader(f, maxUpload+1)` → `io.LimitReader(f, maxUpload)`
+// in createDocument, which truncates instead of refusing → the
+// same failure, at the real cap. This is the mutation worth
+// knowing: an over-cap upload came back "/?stored=1" having
+// quietly lost its last byte.
+//
+// And one that does NOT fail, recorded because a reader will try it:
+// changing maxUpload itself. The page renders that constant, so both
+// sides move together and the test stays green — which is the whole
+// point of publishing the number rather than writing it twice. What
+// this test can catch is the two halves disagreeing, and (a) and (b) are
+// 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"))
+ if cap < 1024 {
+ t.Fatalf("data-max-bytes is %d, which no real cap is; the tests below would prove nothing", cap)
+ }
+
+ // Text, not one long line: the chunker's job is not what is being
+ // 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" {
+ 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"))
+ }
+
+ over := at + "x"
+ rec := upload(t, h, "over-the-cap.txt", over)
+ if loc := rec.Header().Get("Location"); loc != "/?err=toobig" {
+ 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)
+ }
+}
+
+// Same again for the sniff window. looksLikeText reads a prefix and the
+// drop zone reads the same prefix, so the page publishes its length and
+// this proves the handler agrees: a NUL at the last byte inside the
+// window is refused, and a NUL one byte past it is stored.
+//
+// The second half is the one that catches drift. If the handler's window
+// were larger than the page's, the browser would wave through a file the
+// server then rejects — and the person would be told "binary" by a page
+// that had just said the file was fine.
+//
+// MUTATION-TESTED 2026-09-04, each reverted:
+//
+// (a) data-sniff-bytes="64" in index.html → "a NUL at byte 64 — one
+// past the advertised 64-byte window — was answered
+// "/?err=binary", want /?stored=1"
+// (b) looksLikeText's window hard-coded back to 4096 → "a NUL at byte
+// 8191 — the last byte inside the advertised 8192-byte window —
+// was answered "/?stored=1", want /?err=binary"
+//
+// As with the cap: changing sniffBytes itself moves both sides and
+// 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"))
+ if window < 16 {
+ t.Fatalf("data-sniff-bytes is %d; the tests below would prove nothing", window)
+ }
+
+ // A NUL at the last byte the window covers: refused.
+ inside := strings.Repeat("a", window-1) + "\x00" + strings.Repeat("b", 64)
+ if loc := upload(t, h, "inside.txt", inside).Header().Get("Location"); loc != "/?err=binary" {
+ t.Errorf("a NUL at byte %d — the last byte inside the advertised %d-byte window — "+
+ "was answered %q, want /?err=binary", window-1, window, loc)
+ }
+
+ // A NUL one byte past the window: stored, because neither side
+ // 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" {
+ 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 "+
+ "browser it does", window, window, loc)
+ }
+}
+
+// A drop of several files is refused whole, and the refusal says so.
+//
+// This pins the copy, not the behaviour — the behaviour is JavaScript
+// and was checked in a browser (recipe at the top of this file). Pinning
+// the copy is still worth a test, because the copy is where the decision
+// lives: the moment someone rewrites this sentence to "only the first
+// file was stored", they have decided to truncate silently, and the
+// review that lets it through would be a review of a JavaScript diff.
+//
+// MUTATION-TESTED 2026-09-04: rewriting Many in newDropZone to "Only the
+// first file was taken." fails all three assertions at once — no {n}, no
+// "one document at a time", and "does not say the drop was refused
+// whole". Reverted.
+func TestSeveralFilesAtOnceAreRefusedRatherThanTruncated(t *testing.T) {
+ many := dropZoneAttr(t, get(t, newApp(t), "/").Body.String(), "data-msg-many")
+
+ if !strings.Contains(many, "{n}") {
+ t.Errorf("the multi-file refusal does not carry the count placeholder {n}, so it "+
+ "cannot tell the person how many files were refused: %q", many)
+ }
+ if !strings.Contains(many, "one document at a time") {
+ t.Errorf("the multi-file refusal does not say only one file is taken at a time: %q", many)
+ }
+ if !strings.Contains(many, "none of them were taken") {
+ t.Errorf("the multi-file refusal does not say the drop was refused whole. Silently "+
+ "keeping one of five and reporting success is the failure this app treats as "+
+ "its worst: %q", many)
+ }
+}
+
+// The browser refuses in the handler's own words. A person who drops a
+// PDF and a person who picks one from the file chooser are told the same
+// thing, because it is the same sentence — read off flashFor.
+//
+// MUTATION-TESTED 2026-09-04, each reverted: (a) data-msg-toobig set to
+// a literal "That file is too big." → the toobig mismatch, printing both
+// sentences; (b) data-msg-binary pointed at {{.Empty}} → the binary
+// mismatch. Editing flashFor itself moves both sides at once and stays
+// green, which is what sharing the sentence is for.
+func TestTheBrowserRefusesInTheHandlersWords(t *testing.T) {
+ h := newApp(t)
+ tag := get(t, h, "/").Body.String()
+
+ for _, c := range []struct{ code, attr string }{
+ {"toobig", "data-msg-toobig"},
+ {"binary", "data-msg-binary"},
+ } {
+ page := dropZoneAttr(t, tag, c.attr)
+ server := flashText(t, get(t, h, "/?err="+c.code).Body.String())
+ if page != server {
+ t.Errorf("the drop zone and the handler refuse %s differently.\n browser: %q\n server: %q\n"+
+ "Two sentences for one rule means one of them is out of date and nobody can tell which.",
+ c.code, page, server)
+ }
+ }
+}
+
+// servedDropScript fetches the script the page actually links, and
+// proves it is that script before anything is asserted about its
+// contents. Fetched through the handler rather than read off disk:
+// 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()
+ m := regexp.MustCompile(`<script[^>]+src="([^"]*drop[^"]*\.js[^"]*)"`).FindStringSubmatch(body)
+ if m == nil {
+ t.Fatalf("the page links no drop script:\n%s", body)
+ }
+ rec := get(t, h, m[1])
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s: status %d, want 200 — the page links a script that is not served, "+
+ "so the drop zone never appears and nobody finds out from a test", m[1], rec.Code)
+ }
+ src := rec.Body.String()
+ if len(src) < 500 {
+ t.Fatalf("GET %s served %d bytes; that is not the drop script, and every "+
+ "assertion against it would pass against an empty file", m[1], len(src))
+ }
+ // The vacuity guard: prove this is really the file, so an absence
+ // asserted against it means something.
+ if !strings.Contains(src, "rst-dropzone") {
+ t.Fatalf("GET %s did not serve the drop script (no rst-dropzone in it)", m[1])
+ }
+ return src
+}
+
+// The note is a live region, and a live region has to be in the
+// accessibility tree BEFORE the text it announces is written into it.
+// Unhiding it and filling it in the same tick is the common way to build
+// one that never announces anything — and this particular region carries
+// the refusals: "that drop had five files", "that is a folder". A
+// refusal a blind person cannot hear is exactly the silent failure this
+// codebase treats as its worst, so the empty paragraph is unhidden at
+// the ready step and stays there.
+//
+// Go cannot run the script, so this pins the shape instead: exactly one
+// place unhides the note, and it is the ready step at the end of the
+// file rather than say(). The CSS half is checked too — an empty
+// paragraph that still takes a half-line of margin is a visible cost for
+// an invisible element, and the obvious way to "fix" that is to hide the
+// note again, which is the bug coming back.
+//
+// MUTATION-TESTED 2026-09-04, each reverted:
+//
+// (a) moving `note.hidden = false` back into say() → "drop.js unhides
+// the note before the ready step"
+// (b) deleting it entirely → "drop.js never unhides the note"
+// (c) deleting the [rst-dropzone-note]:empty rule from memoria.css →
+// "memoria.css has no :empty rule for the note"
+func TestTheLiveRegionExistsBeforeItHasAnythingToSay(t *testing.T) {
+ h := newApp(t)
+ src := servedDropScript(t, h)
+
+ // The anchor is the ready step's own marker comment. Asserted
+ // first, because without it the position check below compares
+ // against -1 and passes for the wrong reason.
+ const anchor = "and only now does the zone exist"
+ at := strings.Index(src, anchor)
+ if at < 0 {
+ t.Fatalf("drop.js no longer carries the %q marker, so this test cannot tell the "+
+ "ready step from the rest of the file and proves nothing", anchor)
+ }
+
+ unhide := regexp.MustCompile(`note\.hidden\s*=`).FindAllStringIndex(src, -1)
+ if len(unhide) == 0 {
+ t.Fatal("drop.js never unhides the note; the live region stays hidden and every " +
+ "refusal a drop makes is silent to a screen reader")
+ }
+ if len(unhide) > 1 {
+ t.Errorf("drop.js assigns note.hidden in %d places. One of them is the ready step; "+
+ "any other can only be unhiding it later, beside the text — which is the "+
+ "pattern that does not announce.", len(unhide))
+ }
+ for _, u := range unhide {
+ if u[0] < at {
+ t.Errorf("drop.js unhides the note before the ready step (byte %d, marker at %d). "+
+ "The live region must enter the accessibility tree empty and early: unhidden "+
+ "in the same tick as its first message, it is commonly not announced at all.",
+ u[0], at)
+ }
+ }
+
+ // The rendered note is still a live region at all.
+ tag := regexp.MustCompile(`<p rst-dropzone-note[^>]*>`).FindString(get(t, h, "/").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)
+ }
+
+ // And an empty note costs no space.
+ css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !regexp.MustCompile(`\[rst-dropzone-note\]:empty\s*\{[^}]*margin\s*:\s*0`).Match(css) {
+ t.Error("memoria.css has no :empty rule for the note zeroing its margin. The note is " +
+ "unhidden from the moment the script runs, so an empty paragraph with a top " +
+ "margin pushes the Store it button down for no visible reason — and the " +
+ "obvious fix for that is to hide the note again, which is the bug.")
+ }
+}
+
+// The refusal names the cap it enforces. flashFor's "larger than N MiB",
+// the drop zone's "up to N MiB" and maxUpload are one number said three
+// times, and only one of them is written down.
+//
+// Not pedantry: the refusal used to carry "2 MiB" as a literal while the
+// hint beside it derived its number from maxUpload. Raising the cap
+// would have left the page refusing a 3 MiB file with the words "that is
+// larger than 2 MiB" — and the person, who had just read a hint saying
+// 4 MiB, would have no way to tell which of the two lines was lying.
+//
+// MUTATION-TESTED 2026-09-04, reverted: putting the literal back —
+// `"That is larger than 2 MiB. …"` in flashFor — with maxUpload raised
+// to `4 << 20` fails with "the refusal names 2 MiB but the cap it
+// enforces is 4 MiB". Changing maxUpload ALONE keeps this green, which
+// is the point: one number, said everywhere from one place.
+func TestTheRefusalNamesTheCapItEnforces(t *testing.T) {
+ h := newApp(t)
+ body := get(t, h, "/").Body.String()
+
+ capBytes := atoi(t, dropZoneAttr(t, body, "data-max-bytes"))
+ mib := capBytes >> 20
+ if mib < 1 {
+ t.Fatalf("the advertised cap is %d bytes, under a MiB; this test compares whole "+
+ "MiB and would prove nothing", capBytes)
+ }
+ want := fmt.Sprintf("%d MiB", mib)
+
+ // The refusal the handler actually renders.
+ refusal := flashText(t, get(t, h, "/?err=toobig").Body.String())
+ said := regexp.MustCompile(`\d+\s*MiB`).FindAllString(refusal, -1)
+ if len(said) == 0 {
+ t.Fatalf("the too-big refusal names no size at all, so it can neither agree nor "+
+ "disagree with the cap: %q", refusal)
+ }
+ for _, got := range said {
+ if got != want {
+ t.Errorf("the refusal names %s but the cap it enforces is %s (%d bytes, read off "+
+ "the same page the browser reads). One of the two moved and the other did "+
+ "not: %q", got, want, capBytes, refusal)
+ }
+ }
+
+ // And the hint above the input says the same thing, because it is
+ // the same string from the same place.
+ hint := regexp.MustCompile(`<p rst-dropzone-hint[^>]*>([^<]*)</p>`).FindStringSubmatch(body)
+ if hint == nil {
+ t.Fatalf("no drop zone hint on the page:\n%s", body)
+ }
+ if !strings.Contains(hint[1], want) {
+ t.Errorf("the hint says %q while the refusal says %s; a person is told one limit "+
+ "before the upload and a different one after it", strings.TrimSpace(hint[1]), want)
+ }
+}
+
+// flashText pulls the rendered flash message out of a page.
+func flashText(t *testing.T, body string) string {
+ t.Helper()
+ m := regexp.MustCompile(`<p rst-callout-body>([^<]*)</p>`).FindStringSubmatch(body)
+ if m == nil {
+ t.Fatalf("no flash on the page:\n%s", body)
+ }
+ return strings.TrimSpace(unescape(m[1]))
+}
+
+// unescape undoes html/template's attribute and text escaping for the
+// few entities this app's copy can produce. Both sides of the comparison
+// go through it, so a difference is a difference in the copy.
+func unescape(s string) string {
+ return strings.NewReplacer(
+ "&#34;", `"`, "&quot;", `"`,
+ "&#39;", "'", "&apos;", "'",
+ "&amp;", "&", "&lt;", "<", "&gt;", ">",
+ "&#43;", "+",
+ ).Replace(s)
+}
+
+func atoi(t *testing.T, s string) int {
+ t.Helper()
+ n := 0
+ if s == "" {
+ t.Fatal("empty number")
+ }
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ t.Fatalf("%q is not a number", s)
+ }
+ n = n*10 + int(c-'0')
+ }
+ return n
+}
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
index 1d195e0..f6086ba 100644
--- a/internal/memoriatest/gates_test.go
+++ b/internal/memoriatest/gates_test.go
@@ -253,6 +253,97 @@ func TestNoColourLiteralsInAppCSS(t *testing.T) {
}
}
+// Every --rst-* token memoria.css reads is either defined by the
+// stylesheets it ships with, or reached through var()'s fallback.
+//
+// This is the gate for a whole bug class, not for the one bug that
+// prompted it. An undefined custom property does not fall back to
+// anything: the declaration containing it is invalid at computed-value
+// time, so the RULE VANISHES rather than the colour going wrong. Nothing
+// warns, nothing looks broken in the markup, and the page simply renders
+// with body text where a muted grey was meant to be. That is what
+// `--rst-text-mut` (no D) did to the result trail, and it is the same
+// shape as the missing theme.css link AGENTS.md already records.
+//
+// A fallback is allowed and is not a smell: `var(--rst-font-mono,
+// ui-monospace, monospace)` is deliberate — rastrillo defines no mono
+// token and the fallback stack is the whole point. A misspelling with a
+// fallback degrades to the fallback; a misspelling without one deletes
+// the rule. Only the second is a bug, and only the second fails here.
+//
+// MUTATION-TESTED 2026-09-04, each reverted:
+//
+// (a) `color: var(--rst-text-mut)` restored on [rst-result-trail] —
+// the original bug → "memoria.css:121 reads var(--rst-text-mut),
+// which nothing defines and which has no fallback"
+// (b) `background: var(--rst-nonesuch)` added → the same failure at
+// the new line
+// (c) the same reference WITH a fallback, `var(--rst-nonesuch,
+// var(--rst-bg))` → stays green, which is the distinction this
+// gate is drawing
+func TestEveryTokenReferencedHereIsDefinedOrHasAFallback(t *testing.T) {
+ static := filepath.Join("..", "memoria", "static")
+
+ // Where tokens come from. memoria.css itself is included: a page
+ // may define its own token as long as it defines it somewhere.
+ defined := map[string]bool{}
+ def := regexp.MustCompile(`(?:^|[;{,\s])(--rst-[a-zA-Z0-9_-]+)\s*:`)
+ for _, name := range []string{"theme.css", "tokens.css", "memoria.css"} {
+ raw, err := os.ReadFile(filepath.Join(static, name))
+ if err != nil {
+ t.Fatalf("reading %s: %v", name, err)
+ }
+ for _, m := range def.FindAllStringSubmatch(string(raw), -1) {
+ defined[m[1]] = true
+ }
+ }
+ // Vacuity guard, and it has to be here: if the definition regexp
+ // stopped matching, `defined` would be empty and every reference
+ // below would be reported — loudly wrong rather than quietly wrong,
+ // but still not this gate doing its job. theme.css alone carries
+ // well over twenty tokens.
+ if len(defined) < 20 {
+ t.Fatalf("only %d tokens were found across theme.css, tokens.css and memoria.css; "+
+ "the definition pattern has stopped matching and this gate is measuring "+
+ "nothing", len(defined))
+ }
+ for _, must := range []string{"--rst-text-muted", "--rst-line", "--rst-bg"} {
+ if !defined[must] {
+ t.Fatalf("%s was not found among the defined tokens; the definition pattern is "+
+ "missing real definitions and this gate would report false failures", must)
+ }
+ }
+
+ raw, err := os.ReadFile(filepath.Join(static, "memoria.css"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The character after the token name is the whole test: `)` means
+ // "no fallback, this must resolve", `,` means a fallback follows.
+ // Nested var()s in a fallback are separate matches and are checked
+ // on their own terms.
+ use := regexp.MustCompile(`var\(\s*(--rst-[a-zA-Z0-9_-]+)\s*([,)])`)
+ seen := 0
+ for i, line := range strings.Split(string(raw), "\n") {
+ for _, m := range use.FindAllStringSubmatch(line, -1) {
+ seen++
+ if m[2] == "," {
+ continue // a fallback carries it
+ }
+ if !defined[m[1]] {
+ t.Errorf("memoria.css:%d reads var(%s), which nothing defines and which has "+
+ "no fallback. The declaration is then invalid at computed-value time — "+
+ "the whole rule disappears, silently, and the element renders as though "+
+ "the line had never been written.", i+1, m[1])
+ }
+ }
+ }
+ if seen < 5 {
+ t.Fatalf("only %d token references were found in memoria.css; the reference pattern "+
+ "has stopped matching and this gate proves nothing", seen)
+ }
+}
+
// requireSession falls through when auth is nil, which is what makes
// these tests possible. That seam is only safe because the app refuses to
// start without sign-in configured, so this pins the refusals.
diff --git a/tools/drive/main.go b/tools/drive/main.go
index 8c1461c..50b8e0d 100644
--- a/tools/drive/main.go
+++ b/tools/drive/main.go
@@ -32,10 +32,23 @@ import (
func main() {
addr := flag.String("addr", "127.0.0.1:8824", "address to serve on")
+ // The database goes somewhere disposable, and where that is has to
+ // be arguable: this used to be hard-coded to one machine's scratch
+ // directory, which does not exist on any other, and drive then
+ // panicked before serving a byte — a poor reason not to look at the
+ // page.
+ //
+ // Empty means os.TempDir(), which MkdirTemp reads for itself. That
+ // is the default rather than a named path because MkdirTemp does NOT
+ // create the parent: any default that names a directory is a default
+ // that panics on the first machine where it is missing, and the
+ // recipe in drop_test.go would have handed the next reader exactly
+ // that panic.
+ parent := flag.String("dir", "", "parent directory for the throwaway database (default: the system temp directory)")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
- dir, err := os.MkdirTemp("/home/paulca/memoriadrive", "drive")
+ dir, err := os.MkdirTemp(*parent, "drive")
if err != nil {
panic(err)
}