oficina / sheets Public

feat(list): the drop comes back, without the box

Taking the dashed drop zone off the sheet list took dropping a file on
it away too. That was a real loss and not the point of the change: the
point was that a list of sheets should be a list of sheets, not that
dragging should stop working.

So the drop target is back and the box is not. The list carries an
overlay that is `hidden` at rest — no paint, no layout, no tab stop —
and drop.js reveals it only while a drag actually carrying files is over
the window. An affordance that appears exactly when it applies beats a
permanent rectangle explaining that it exists.

It is the same form /sheets/import serves, so a drop is the ordinary
multipart POST: one route, one CSRF check, one code path on the server.
With scripting off it never appears and the Import button in the top bar
is the whole feature — which is what AGENTS.md's "every drag-only
affordance needs a menu equivalent" asks for. The panel keeps a chooser
and a submit for after a refusal, hidden while a drag is in progress
because offering a file picker to somebody mid-drag is a second way to
do the thing they are already doing.

tools/drive/drop.mjs is the gate, and it earned its place on the first
run. The overlay carries data-drop, because that is what drop.js looks
for, so it also matched [data-drop][data-drop-over] — 0-2-0 against
[rst-modal-overlay]'s 0-1-0. The scrim was painted accent-soft and the
sheet list disappeared behind a flat blue screen. Every Go test passed.
Both halves of that are now pinned: the browser checks the scrim is
translucent, and a Go test reads sheets.css for the :not() exclusion and
for the rule reinstating display:none under [hidden] — without which the
overlay covers the list permanently, worst with scripting off, where
nothing would ever come along to hide it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wx6hf3dY5vmSNmR9RCGkh2
Paul Campbell pushed by paul@keymail.dev 4ea40315e1122c60ecd1ba157b51d58a315ec38b parent 51bc3e0
5 files changed, +528 −14
  • internal/sheets/handlers_list_chrome_test.go +92 −6
  • internal/sheets/static/drop.js +56 −6
  • internal/sheets/static/sheets.css +80 −2
  • internal/sheets/templates/sheets.html +67 −0
  • tools/drive/drop.mjs +233 −0
diff --git a/internal/sheets/handlers_list_chrome_test.go b/internal/sheets/handlers_list_chrome_test.go
index c779f18..8282dee 100644
--- a/internal/sheets/handlers_list_chrome_test.go
+++ b/internal/sheets/handlers_list_chrome_test.go
@@ -5,6 +5,8 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
)
@@ -27,24 +29,108 @@ func signedInListBody(t *testing.T) string {
return listBody(t, app, cookies)
}
+// dropOverlay returns the sheet list's body up to the drop overlay, and
+// the overlay's own start tag. Everything before the overlay is what
+// the page actually renders; the overlay itself is hidden until a file
+// is dragged over the window.
+func dropOverlay(t *testing.T, body string) (inFlow, tag string) {
+ t.Helper()
+ start := strings.Index(body, "<form data-drop")
+ if start < 0 {
+ t.Fatal("the sheet list has no drop overlay — dropping a file on this page is the affordance it traded its drop box for")
+ }
+ end := strings.Index(body[start:], ">")
+ if end < 0 {
+ t.Fatal("the drop overlay's start tag never closes")
+ }
+ return body[:start], body[start : start+end+1]
+}
+
func TestSheetListCarriesNoInlineForms(t *testing.T) {
body := signedInListBody(t)
- // The title field and the file input are the two controls that were
- // on this page and must not be, named individually so a failure
- // says which one came back.
+ // The title field is simply gone. A sheet is named by renaming it,
+ // on the sheet itself, so there is nothing left for this to be.
+ if strings.Contains(body, `name="title"`) {
+ t.Error(`the sheet list is carrying name="title" again — naming a sheet is a rename, in the top bar of the sheet`)
+ }
+
+ // The file input is back, but only inside the overlay. Anything of
+ // it appearing BEFORE the overlay's start tag is a box in the page
+ // again, which is the thing this change removed.
+ inFlow, _ := dropOverlay(t, body)
for _, gone := range []string{
- `name="title"`,
`type="file"`,
`enctype="multipart/form-data"`,
"data-drop",
} {
- if strings.Contains(body, gone) {
- t.Errorf("the sheet list is carrying %s again — that belongs in the top bar or on /sheets/import", gone)
+ if strings.Contains(inFlow, gone) {
+ t.Errorf("%s renders in the flow of the sheet list — the drop target is an overlay, not a box above the sheets", gone)
}
}
}
+// The overlay is a real import form that happens to be invisible until
+// a drag brings it up. Both halves of that matter: hidden at rest, so
+// it costs the list no layout and no tab stop, and a genuine multipart
+// POST to /sheets/import, so a drop is the ordinary upload and there is
+// no second code path on the server.
+func TestTheDropOverlayIsHiddenAtRest(t *testing.T) {
+ _, tag := dropOverlay(t, signedInListBody(t))
+
+ for _, want := range []string{
+ "hidden",
+ `method="post"`,
+ `action="/sheets/import"`,
+ `enctype="multipart/form-data"`,
+ } {
+ if !strings.Contains(tag, want) {
+ t.Errorf("the drop overlay's start tag is missing %s: %s", want, tag)
+ }
+ }
+}
+
+// The overlay is nothing without drop.js: it is `hidden`, and only that
+// script ever takes the attribute off. A page that renders the markup
+// and does not load the script has an import affordance that can never
+// appear.
+func TestTheSheetListLoadsDropJS(t *testing.T) {
+ // By prefix, not by filename: assets are served fingerprinted, so
+ // the tag reads /static/drop.<hash>.js and matching the literal
+ // name passes only until the file next changes.
+ body := signedInListBody(t)
+ if !strings.Contains(body, `src="/static/drop.`) {
+ t.Error("the sheet list renders the drop overlay but never loads drop.js, so nothing will ever reveal it")
+ }
+}
+
+// The paint that keeps the overlay hidden.
+//
+// [rst-modal-overlay] sets display:flex, and an author declaration beats
+// the user agent's [hidden] { display: none } however weak its selector
+// looks. Drop the reinstating rule and the overlay covers the sheet list
+// permanently — worst with scripting off, where nothing would ever come
+// along to hide it again. The markup test above passes either way, so
+// this reads the stylesheet.
+func TestTheDropOverlayStaysHiddenInCSS(t *testing.T) {
+ css, err := os.ReadFile(filepath.Join("static", "sheets.css"))
+ if err != nil {
+ t.Fatalf("read sheets.css: %v", err)
+ }
+ if !strings.Contains(string(css), "[data-drop-page][hidden]") {
+ t.Error("sheets.css no longer reinstates display:none for the hidden drop overlay — it will cover the sheet list")
+ }
+
+ // The drop BOX's paint must exclude the overlay, which carries
+ // data-drop too. [data-drop][data-drop-over] is 0-2-0 against
+ // [rst-modal-overlay]'s 0-1-0, so without the exclusion the scrim is
+ // painted accent-soft and the sheet list disappears behind a flat
+ // blue screen mid-drag. A browser found that; nothing here could.
+ if !strings.Contains(string(css), "[data-drop]:not([data-drop-page])") {
+ t.Error("the drop box's paint no longer excludes the overlay — it will out-specify the modal scrim and cover the list")
+ }
+}
+
// Create is a real form posting to /sheets, in the top bar. A link
// would be wrong twice over: it makes a sheet, so it must not be a GET,
// and it has to work with scripting off.
diff --git a/internal/sheets/static/drop.js b/internal/sheets/static/drop.js
index 30baa12..3a89703 100644
--- a/internal/sheets/static/drop.js
+++ b/internal/sheets/static/drop.js
@@ -1,4 +1,11 @@
-/* Drag and drop for the import form.
+/* Drag and drop for the import form, in both places it appears.
+ *
+ * On /sheets/import the zone is a box in the page. On the sheet list it
+ * is an overlay that stays `hidden` until a drag carrying files is
+ * actually over the window — the list carries no visible drop box,
+ * because a list of sheets should be a list of sheets. Which shape it
+ * is comes from data-drop-page on the zone, and it changes only where
+ * the events are listened for and whether the zone is revealed.
*
* This file adds no capability. The form it decorates already imports a
* spreadsheet with scripting off, and every drop here ends as that same
@@ -32,8 +39,32 @@
if (typeof window.DataTransfer !== 'function') return;
var hint = zone.querySelector('[data-drop-hint]');
+ var hintText = hint ? hint.textContent : '';
var depth = 0;
+ /* Two shapes, one behaviour.
+ *
+ * On /sheets/import the zone is a box in the page and the events
+ * belong to it. On the sheet list there is no box — a list of sheets
+ * should be a list of sheets — so the zone is an overlay that is
+ * `hidden` until a drag carrying files enters the window, and the
+ * events belong to the document because a hidden element receives
+ * none. Everything after this line is the same either way. */
+ var page = zone.hasAttribute('data-drop-page');
+ var source = page ? document : zone;
+
+ function reveal() { if (page) zone.hidden = false; }
+
+ /* dismiss puts the hint back as well. Otherwise a refusal ("drop one
+ * file at a time") is still sitting there the next time the overlay
+ * comes up, answering a drag that has not happened yet. */
+ function dismiss() {
+ if (!page) return;
+ zone.hidden = true;
+ zone.removeAttribute('data-drop-over');
+ say(hintText);
+ }
+
/* dragenter/dragleave fire for every child element the pointer
* crosses, so a plain enter/leave pair flickers the whole time a
* file is moved across the box. Counting depth is what makes the
@@ -42,6 +73,7 @@
if (!hasFiles(e)) return;
e.preventDefault();
depth++;
+ reveal();
zone.setAttribute('data-drop-over', '');
}
@@ -49,7 +81,10 @@
if (!hasFiles(e)) return;
e.preventDefault();
depth = Math.max(0, depth - 1);
- if (depth === 0) zone.removeAttribute('data-drop-over');
+ if (depth === 0) {
+ zone.removeAttribute('data-drop-over');
+ dismiss();
+ }
}
/* dragover must preventDefault on every tick or the browser refuses
@@ -117,8 +152,23 @@
if (hint) hint.textContent = text;
}
- zone.addEventListener('dragenter', enter);
- zone.addEventListener('dragleave', leave);
- zone.addEventListener('dragover', over);
- zone.addEventListener('drop', drop);
+ source.addEventListener('dragenter', enter);
+ source.addEventListener('dragleave', leave);
+ source.addEventListener('dragover', over);
+ source.addEventListener('drop', drop);
+
+ /* A refused drop leaves the overlay up on purpose — the message is
+ * the whole point of refusing in the client's own words, and the
+ * chooser beside it is how the person recovers. So the overlay needs
+ * a way out that is not the back button: Escape, or a click on the
+ * backdrop outside the panel. Both are what every other dismissible
+ * layer in this app answers to, so neither has to be taught. */
+ if (page) {
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape' && !zone.hidden) dismiss();
+ });
+ zone.addEventListener('click', function (e) {
+ if (e.target === zone) dismiss();
+ });
+ }
})();
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index 677b103..be9c11d 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -1027,14 +1027,21 @@ form[data-doc-title]:not(:focus-within) button {
* page crosses this box repeatedly, and animating the outline is the
* difference between a box that acknowledges the drag and one that
* flashes. */
-[data-drop] {
+/* :not([data-drop-page]) on both, because there are two zones now and
+ only one of them is a box. The overlay on the sheet list carries
+ data-drop too — it has to, it is what drop.js looks for — and these
+ two selectors are 0-2-0 and 0-3-0, so without the exclusion they beat
+ [rst-modal-overlay]'s 0-1-0 and paint the sheet of glass over the
+ whole page in accent-soft. It renders as a flat blue screen with the
+ list gone: a defect a browser found and no test could. */
+[data-drop]:not([data-drop-page]) {
border: 1px dashed var(--rst-line);
border-radius: var(--rst-radius);
padding: var(--rst-sp-3);
transition: border-color 120ms ease, background-color 120ms ease;
}
-[data-drop][data-drop-over] {
+[data-drop][data-drop-over]:not([data-drop-page]) {
border-color: var(--rst-accent);
background: var(--rst-accent-soft);
}
@@ -1047,6 +1054,77 @@ form[data-doc-title]:not(:focus-within) button {
}
}
+/* ── The sheet list's drop overlay ───────────────────────────────────
+ *
+ * The list has no drop box. It has a full-window target that does not
+ * exist until a file is over the window, and drop.js is what reveals
+ * it — see the comment on [data-drop-page] in sheets.html.
+ *
+ * It borrows rastrillo's modal paint rather than growing its own, so
+ * the layer that appears over the list looks like every other layer in
+ * the suite. Only the four rules below are ours.
+ *
+ * THIS FIRST RULE IS LOAD-BEARING. [rst-modal-overlay] sets
+ * display: flex, and an author declaration beats the user agent's
+ * [hidden] { display: none } no matter how weak its selector looks.
+ * Without it the overlay covers the sheet list permanently and the page
+ * is unusable — with scripting off, where nothing would ever come along
+ * to hide it again. TestTheDropOverlayIsHiddenAtRest pins the markup;
+ * this pins the paint. */
+[data-drop-page][hidden] {
+ display: none;
+}
+
+[data-drop-page] [rst-modal-panel] {
+ max-width: 24rem;
+ transition: border-color 120ms ease;
+}
+
+/* The panel answers the drag, the same way the box on /sheets/import
+ does and with the same token, so the two read as one gesture. */
+[data-drop-page][data-drop-over] [rst-modal-panel] {
+ border-color: var(--rst-accent);
+}
+
+[data-drop-page-body] {
+ display: grid;
+ gap: var(--rst-sp-3);
+ justify-items: center;
+ padding: var(--rst-sp-5);
+ text-align: center;
+ width: 100%;
+}
+
+[data-drop-page-title] {
+ font-size: var(--rst-fs-base);
+ font-weight: 600;
+ margin: 0;
+}
+
+/* The chooser is for afterwards.
+ *
+ * While a file is over the window the panel has one thing to say, and a
+ * "Choose File" button beside "Drop it to import" offers a second way to
+ * do the thing the person is already doing. It appears when the overlay
+ * is up WITHOUT a drag over it, which is exactly the state a refused
+ * drop leaves behind — and then it is the way out of that refusal. */
+[data-drop-page-recover] {
+ display: grid;
+ gap: var(--rst-sp-3);
+ justify-items: center;
+ width: 100%;
+}
+
+[data-drop-page][data-drop-over] [data-drop-page-recover] {
+ display: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-drop-page] [rst-modal-panel] {
+ transition: none;
+ }
+}
+
/* ── Cell formatting ─────────────────────────────────────────────────
*
* The paint for a cell's stored Format: bold, italic, alignment, fill
diff --git a/internal/sheets/templates/sheets.html b/internal/sheets/templates/sheets.html
index 4da71db..78534f1 100644
--- a/internal/sheets/templates/sheets.html
+++ b/internal/sheets/templates/sheets.html
@@ -11,6 +11,19 @@
handlers_rename.go is for. Import moved to a page of its own at
/sheets/import — see handleImportPage for why a page and not an
item in the File menu. */}}
+{{define "head"}}
+{{/* Drag and drop, and only that. The overlay at the foot of this file
+ is hidden until a file is dragged over the window, and every drop
+ ends as a submit of the same form /sheets/import serves — same
+ route, same CSRF check, same job.
+
+ With scripting off it never appears and the Import link in the top
+ bar is the entire feature. That is what AGENTS.md's "every
+ drag-only affordance needs a menu equivalent" asks for: the link
+ is the floor, and the drag is a shortcut to it. */}}
+<script defer src="{{asset "static/drop.js"}}"></script>
+{{end}}
+
{{define "topbaractions"}}
{{if .SignedIn}}
{{/* The wrapper is what carries the auto margin that puts these
@@ -77,4 +90,58 @@ grid you can type into straight away, or
already have.</p>
</div>
{{end}}
+
+{{if .SignedIn}}
+{{/* The drop target for the whole page.
+
+ This screen used to carry a dashed box you could drop a file on,
+ and taking the box away took the drop with it — so the drop comes
+ back without the box. It is `hidden` at rest, contributes nothing
+ to the layout and nothing to the tab order, and drop.js reveals it
+ only while a drag carrying files is actually over the window. An
+ affordance that appears exactly when it applies beats a permanent
+ rectangle explaining that it exists.
+
+ It is the same form as the one on /sheets/import, deliberately:
+ drop.js fills this file input and submits this form, so a drop
+ from here is the ordinary multipart POST and there is no second
+ code path on the server. The file field is visible inside the
+ panel rather than hidden, because a refused drop ("drop one file
+ at a time") needs something to recover with that is not the back
+ button.
+
+ `hidden` is load-bearing and is asserted in a test: rst-modal-overlay
+ sets display:flex, an author rule beats the UA's [hidden], and
+ without the reinstating rule in sheets.css this would cover the
+ list permanently. */}}
+<form data-drop data-drop-page hidden rst-modal-overlay
+ method="post" action="/sheets/import" enctype="multipart/form-data">
+<div rst-modal-panel>
+<div data-drop-page-body>
+<p data-drop-page-title>Drop it to import</p>
+{{/* The same limits /sheets/import states, and stated before the
+ import for the same reason: one worksheet is read, and a
+ three-sheet workbook would otherwise arrive looking whole. This
+ is also the line drop.js rewrites to refuse a drop, which is
+ why it is a sentence and not a list. */}}
+<p rst-field-hint data-drop-hint>Excel .xlsx or .xlsm, up to 20 MB. The
+first worksheet is imported.</p>
+{{/* Chooser and submit, so a refused drop has something to recover
+ with, and so the panel is a working form rather than a picture
+ of one. They are hidden while a drag is actually over the window
+ — see [data-drop-page-recover] in sheets.css — because offering
+ a file picker to somebody mid-drag is a second way to do the
+ thing they are already doing.
+
+ tabindex="-1" keeps them out of the tab order while the overlay
+ is up; `hidden` on the form handles it the rest of the time. */}}
+<div data-drop-page-recover>
+<input rst-input name="file" type="file" accept=".xlsx,.xlsm" tabindex="-1"
+ aria-label="Spreadsheet to import">
+<button rst-btn="primary" type="submit" tabindex="-1">Import</button>
+</div>
+</div>
+</div>
+</form>
+{{end}}
{{end}}
diff --git a/tools/drive/drop.mjs b/tools/drive/drop.mjs
new file mode 100644
index 0000000..542035e
--- /dev/null
+++ b/tools/drive/drop.mjs
@@ -0,0 +1,233 @@
+/* Drive the sheet list's drop overlay in a real browser.
+ *
+ * The third of the drives, after drive.mjs (live sync across two tabs)
+ * and chrome.mjs (the editor chrome). Like both it is NOT part of
+ * `make test`: it needs a browser and a running instance, neither of
+ * which CI has.
+ *
+ * It is here because the Go suite can assert the markup and read the
+ * stylesheet, and neither tells you whether the thing works. The list
+ * has no visible drop box any more — a list of sheets should be a list
+ * of sheets — so the whole affordance is an overlay that must be
+ * invisible at rest, appear when a file is over the window, and go away
+ * again. "Invisible" and "appears" are not things a Go test can see.
+ *
+ * It earned its place on the first run. The overlay carries data-drop,
+ * because that is what drop.js looks for, and so it also matched
+ * [data-drop][data-drop-over] — 0-2-0 against [rst-modal-overlay]'s
+ * 0-1-0. The scrim was painted accent-soft and the sheet list vanished
+ * behind a flat blue screen. Every Go test passed.
+ *
+ * Usage, from the repository root — see drive.mjs for the full recipe:
+ *
+ * SHEETS_SOCK=$D/s.sock SHEETS_LOG=$D/s.log OUT=$D \
+ * SHEETS_FIXTURE=testdata/corpus/xlsx/numbers.xlsx \
+ * node tools/drive/drop.mjs
+ *
+ * The drag events are synthesised rather than performed: Playwright
+ * cannot drag a file in from the desktop, and a DragEvent carrying a
+ * real DataTransfer of real File objects is what the browser delivers
+ * when it does. The final drop is a genuine multipart upload — the
+ * import at the end really imports.
+ */
+import net from 'node:net';
+import fs from 'node:fs';
+const PLAYWRIGHT = process.env.PLAYWRIGHT || 'playwright';
+const pw = await import(PLAYWRIGHT);
+/* A CJS build imported from ESM puts its exports on .default. */
+const chromium = pw.chromium || (pw.default && pw.default.chromium);
+if (!chromium) { console.error('playwright: no chromium export'); process.exit(1); }
+
+const SOCK = process.env.SHEETS_SOCK;
+const LOG = process.env.SHEETS_LOG;
+const PORT = parseInt(process.env.SHEETS_PORT || '8799', 10);
+const OUT = process.env.OUT || '.';
+const FIXTURE = process.env.SHEETS_FIXTURE || 'testdata/corpus/xlsx/numbers.xlsx';
+
+const proxy = net.createServer((c) => {
+ const u = net.connect(SOCK);
+ c.pipe(u); u.pipe(c);
+ c.on('error', () => u.destroy());
+ u.on('error', () => c.destroy());
+});
+await new Promise((r) => proxy.listen(PORT, '127.0.0.1', r));
+
+const base = `http://127.0.0.1:${PORT}`;
+const browser = await chromium.launch();
+const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
+const page = await ctx.newPage();
+
+const errors = [];
+page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
+page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
+
+const failed = [];
+function check(name, ok, detail) {
+ console.log((ok ? ' ok ' : ' FAIL ') + name + (detail === undefined ? '' : ' ' + JSON.stringify(detail)));
+ if (!ok) failed.push(name);
+}
+
+/* Sign in. The magic link is read out of the log, which only works on a
+ * loopback origin with no SMTP — see internal/sheets/auth.go. */
+const before = fs.readFileSync(LOG, 'utf8').length;
+await page.goto(base + '/signin');
+await page.fill('#address', 'paul@example.com');
+await page.click('form[action="/signin"] button[type=submit]');
+await page.waitForURL(/signin\?sent=1/, { timeout: 15000 });
+const link = fs.readFileSync(LOG, 'utf8').slice(before).match(/auth\/verify\?token=([A-Za-z0-9_-]+)/);
+if (!link) { console.error('no magic link in the log'); process.exit(1); }
+await page.goto(`${base}/auth/verify?token=${link[1]}`);
+await page.goto(base + '/sheets');
+
+console.log('\n— at rest');
+
+const rest = await page.evaluate(() => {
+ const z = document.querySelector('[data-drop-page]');
+ if (!z) return null;
+ const r = z.getBoundingClientRect();
+ const input = z.querySelector('input[type=file]');
+ return {
+ hidden: z.hidden,
+ display: getComputedStyle(z).display,
+ area: r.width * r.height,
+ reachable: input ? input.offsetParent !== null : null,
+ };
+});
+check('the overlay is in the page', rest !== null);
+check('...hidden', rest && rest.hidden === true && rest.display === 'none', rest);
+check('...costing no layout', rest && rest.area === 0, rest && rest.area);
+check('...and no tab stop', rest && rest.reachable === false, rest && rest.reachable);
+await page.screenshot({ path: OUT + '/list-at-rest.png' });
+
+/* Synthesised drags. __mkdt builds a DataTransfer of n copies of the
+ * fixture; __fire dispatches at the centre of the viewport, on whatever
+ * element is actually there, so the event bubbles the way a real one
+ * does rather than being handed straight to the listener. */
+const helper = `
+ window.__mkdt = (b64, n) => {
+ const bin = atob(b64);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ const dt = new DataTransfer();
+ for (let i = 0; i < n; i++) {
+ dt.items.add(new File([bytes], (i ? i + '-' : '') + 'drop.xlsx',
+ { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
+ }
+ return dt;
+ };
+ window.__fire = (type, dt) => {
+ const e = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt });
+ (document.elementFromPoint(640, 400) || document.body).dispatchEvent(e);
+ };
+`;
+const b64 = fs.readFileSync(FIXTURE).toString('base64');
+const drag = (n) => page.evaluate(([b64, n]) => {
+ window.__dt = window.__mkdt(b64, n);
+ window.__fire('dragenter', window.__dt);
+ window.__fire('dragover', window.__dt);
+}, [b64, n]);
+
+console.log('\n— a file over the window');
+
+await page.evaluate(helper);
+await drag(1);
+const dragging = await page.evaluate(() => {
+ const z = document.querySelector('[data-drop-page]');
+ const r = z.getBoundingClientRect();
+ return {
+ hidden: z.hidden,
+ over: z.hasAttribute('data-drop-over'),
+ w: Math.round(r.width), h: Math.round(r.height),
+ scrim: getComputedStyle(z).backgroundColor,
+ recover: getComputedStyle(z.querySelector('[data-drop-page-recover]')).display,
+ };
+});
+check('the overlay appears', dragging.hidden === false && dragging.over === true, dragging);
+check('...covering the window', dragging.w >= 1200 && dragging.h >= 700, dragging);
+/* The list must still be visible behind it. This is the check that
+ * caught the specificity bug in the header comment: an opaque backdrop
+ * reads as the page having gone away. */
+check('...over a translucent scrim, with the list still there',
+ /^rgba\(/.test(dragging.scrim) && parseFloat(dragging.scrim.split(',')[3]) < 0.8, dragging.scrim);
+check('...and the chooser out of the way', dragging.recover === 'none', dragging.recover);
+await page.screenshot({ path: OUT + '/list-dragging-light.png' });
+
+await page.evaluate(() => window.__fire('dragleave', window.__dt));
+check('the overlay goes when the drag leaves',
+ (await page.evaluate(() => document.querySelector('[data-drop-page]').hidden)) === true);
+
+console.log('\n— a drop it cannot take');
+
+await page.evaluate(([b64]) => {
+ const dt = window.__mkdt(b64, 2);
+ window.__fire('dragenter', dt);
+ window.__fire('drop', dt);
+}, [b64]);
+const refused = await page.evaluate(() => {
+ const z = document.querySelector('[data-drop-page]');
+ return {
+ hidden: z.hidden,
+ hint: z.querySelector('[data-drop-hint]').textContent.trim(),
+ recover: getComputedStyle(z.querySelector('[data-drop-page-recover]')).display,
+ path: location.pathname,
+ };
+});
+check('two files are refused in words', refused.hint === 'Drop one file at a time.', refused);
+check('...with the overlay left up to say so', refused.hidden === false, refused);
+check('...and the chooser back, to recover with', refused.recover !== 'none', refused.recover);
+check('...having submitted nothing', refused.path === '/sheets', refused.path);
+await page.screenshot({ path: OUT + '/list-refused.png' });
+
+await page.keyboard.press('Escape');
+const dismissed = await page.evaluate(() => {
+ const z = document.querySelector('[data-drop-page]');
+ return { hidden: z.hidden, hint: z.querySelector('[data-drop-hint]').textContent.trim() };
+});
+check('Escape dismisses it', dismissed.hidden === true, dismissed);
+check('...and the refusal does not outlive the dismissal',
+ dismissed.hint.startsWith('Excel'), dismissed.hint);
+
+console.log('\n— dark');
+
+await ctx.addCookies([{ name: 'sheets_mode', value: 'dark', url: base }]);
+await page.goto(base + '/sheets');
+await page.evaluate(helper);
+await drag(1);
+/* The cookie is sheets_mode. Naming it "mode" renders the light page
+ * and the screenshot proves nothing — which it once did, and the only
+ * tell was that the two images were pixel-identical. */
+check('dark really is dark', (await page.getAttribute('html', 'data-theme')) === 'dark');
+await page.screenshot({ path: OUT + '/list-dragging-dark.png' });
+
+console.log('\n— the drop imports');
+
+await ctx.addCookies([{ name: 'sheets_mode', value: 'light', url: base }]);
+await page.goto(base + '/sheets');
+await page.evaluate(helper);
+await page.evaluate(([b64]) => {
+ const dt = window.__mkdt(b64, 1);
+ window.__fire('dragenter', dt);
+ window.__fire('drop', dt);
+}, [b64]);
+let landed = 'never left /sheets';
+try {
+ await page.waitForURL((u) => !u.pathname.endsWith('/sheets'), { timeout: 20000 });
+ landed = new URL(page.url()).pathname;
+} catch (e) { /* landed stays as it was */ }
+check('a drop starts a real import', /^\/(jobs|sheets)\//.test(landed), landed);
+
+let arrived = false;
+for (let i = 0; i < 60; i++) {
+ await page.goto(base + '/sheets');
+ if (await page.locator('[data-sheet-list] li').count() > 0) { arrived = true; break; }
+ await page.waitForTimeout(500);
+}
+check('...and the sheet lands in the list', arrived);
+await page.screenshot({ path: OUT + '/list-after-import.png' });
+
+check('no console errors', errors.length === 0, errors.slice(0, 8));
+
+console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nall checks passed');
+await browser.close();
+proxy.close();
+process.exit(failed.length ? 1 : 0);