+
{receipt.state === 'committed' ? 'Published — DB commit confirmed' : receipt.state === 'pending' ? 'Transferring / awaiting DB publication' : receipt.state === 'unknown' ? 'Outcome unknown — do not reupload' : 'Not committed'}
{#if receipt.error}
{receipt.error}
{/if}
{#if receipt.state === 'unknown'}
reconcileReceipt(receipt.requestId)}>Check request result {/if}
Request ID {receipt.requestId}
@@ -295,43 +255,300 @@
{/each}
{/if}
+{/snippet}
+
+
+
+ {#if breadcrumbError}{breadcrumbError}
{/if}
+ {#if !form}{@render notices()}{/if}
+ {#if copyState}{copyState} {#if entry}Latest file link {/if}
{/if}
+ {#if $controller.loading}Loading Drive…
{/if}
+ {#if entry}
+ {#if kind === 'folder'}
+
+
+
+ {$controller.search ? `Results for “${$controller.search.query}”` : 'Files and folders'}
+ {$controller.entries.length}{$controller.nextAfter ? '+' : ''} items
+
+ {#if !$controller.loading && !$controller.error && !$controller.entries.length}
+
+
+
{$controller.search ? 'No matching files.' : 'This folder is empty.'}
+
{$controller.search ? 'Try a different name, or include file contents in your search.' : 'Upload a file or create a document to start sharing.'}
+
+ {/if}
+ {#if $controller.entries.length}
+
+
+
+ Name Size Modified Open
+
+ {#each $controller.entries as item (item.entry.node_id)}
+
+
+
+ {item.name} {typeLabel(item)}
+
+ {item.kind === 'folder' ? '—' : formatBytes(item.size)}
+ {formatDate(item.updated_at)}
+
+
+ {/each}
+
+
+
+ {/if}
+ {#if $controller.nextAfter}{/if}
+
+ {:else if kind === 'markdown' || kind === 'text'}
+ {#if $controller.truncated}Preview truncated at 64 KiB. Download the full file; partial content cannot be saved.
{/if}
+ {#if draft?.conflict}
+ Revision conflict — your draft is retained. Another writer changed this file. Refresh displays the latest content without changing your draft or its expected revision. Copy your draft before explicitly discarding it; it is never automatically retried.
+ {/if}
+
+ {#if editing && draft}
+
+
Draft (expected revision {draft.expectedRevision})
+
+
+
+ {#if canWrite} DRIVE_TEXT_MAX_BYTES} onclick={() => action(() => controller.save())}>Save {/if}
+ { if (confirm('Discard your draft and load the current Backend revision?')) void action(() => controller.discardDraft()); }}>Discard draft / load latest
+ {dirty ? 'Unsaved draft' : 'Saved content'} · {formatBytes(new TextEncoder().encode(draft.text).length)} / 64 KiB
+
+
+ {/if}
+ {#if $controller.text !== null}
+
+ {#if editing}Saved version
{/if}
+ {#if $controller.text === ''}Empty file.
{:else if kind === 'markdown'} {:else}{$controller.text} {/if}
+
+ {/if}
+
+ {:else if kind === 'image'}
+
+ {#if (entry.size ?? Infinity) > DRIVE_RESPONSE_MAX_BYTES}
Image exceeds the 256 KiB preview limit. Download to view.
+ {:else if imageFailed}
Image could not be decoded. Download is available; no image is assumed.
+ {:else if imageUrl}
+ {#key imageUrl}
{ if (event.currentTarget.getAttribute('src') === imageUrl) imageFailed = true; }} />{/key}
+ {/if}
+
+ {:else if kind === 'download'}
+ No preview available This file type is download-only. HTML, SVG and other active content are never rendered here.
Download file
+ {/if}
+ {#if entry.parent}
+ Details
+
Updated {formatDate(entry.updated_at)}
By {entry.updated_by}
Revision {entry.revision}
+ Saving replaces the current content. There is no version history.
+
+ {/if}
+ {/if}
+ {#if !form}{@render receipts()}{/if}
+
{ event.preventDefault(); closeForm(); }} onclose={() => { form = null; if (returnFocus?.isConnected) returnFocus.focus(); }}>
+ {#if form && entry}
+
+ {/if}
+
+
diff --git a/web/workspace/test/drive-browser/README.md b/web/workspace/test/drive-browser/README.md
index 9e808a2db..d02e9d898 100644
--- a/web/workspace/test/drive-browser/README.md
+++ b/web/workspace/test/drive-browser/README.md
@@ -47,4 +47,6 @@ Home/sidebar and Workers are captured under identical owner/member/error/empty/l
The production shell scrolls `main` internally, so `fullPage` does not expand that scroll panel. Capture points use ordinary keyboard focus to show the actual form or the top denial state; no CSS/DOM screenshot override is applied. The responsive audit separately records document/main/table-region geometry, visible keyboard focus, and page errors across the full theme/viewport matrix. Raster checks wait for actual decoding, assert natural dimensions 1200×800, preserve the 3:2 aspect ratio, and constrain the rendered box by both the available width and 60vh height at all four widths/both themes.
+The Drive browser suite also exercises the file-browser redesign at 1440/768/390/320px in light/dark themes: menu placement, native-dialog focus and Escape restoration, folder creation, search, and destination-folder navigation. Screenshots are written to `.svelte-kit/drive-redesign/` for the list, New menu, creation dialog, Markdown preview, and move dialog. Component tests cover failed/paged destination reads, exclusion of files and the moving folder itself, and pinned mutation revisions. These checks use the same fixture boundary, not a live Backend.
+
Parent and reviewer must open images and inspect context before asserting visual acceptance. In particular, a successful capture command is not sufficient and HTTP 200 does not excuse visible UI errors.
diff --git a/web/workspace/test/drive-browser/capture-visual.ts b/web/workspace/test/drive-browser/capture-visual.ts
index ac15748f8..fd0d45e41 100644
--- a/web/workspace/test/drive-browser/capture-visual.ts
+++ b/web/workspace/test/drive-browser/capture-visual.ts
@@ -68,6 +68,7 @@ for (const scheme of ["light", "dark"]) {
label: "Rename / move pinned revision",
fullPage: true,
interaction: [
+ { action: "click", selector: '.drive-menu > summary[aria-label="More actions"]' },
{ action: "click", selector: '.drive-toolbar button:has-text("Rename / move")' },
{
action: "wait",
@@ -82,6 +83,7 @@ for (const scheme of ["light", "dark"]) {
fullPage: true,
interaction: [
{ action: "click", selector: '.drive-form button:has-text("Cancel form")' },
+ { action: "click", selector: '.drive-menu > summary[aria-label="More actions"]' },
{ action: "click", selector: '.drive-toolbar button:has-text("Replace file")' },
{
action: "wait",
@@ -244,6 +246,7 @@ for (const scheme of ["light", "dark"]) {
label: "Member after typed write denial",
fullPage: true,
interaction: [
+ { action: "click", selector: '.drive-create-menu > summary' },
{
action: "click",
selector: '.drive-page button:has-text("New folder")',
@@ -261,12 +264,12 @@ for (const scheme of ["light", "dark"]) {
action: "wait",
ready: {
kind: "selector",
- selector: '.drive-page:has-text("Read only / access denied by Backend")',
+ selector: '.drive-dialog:has-text("Read only / access denied by Backend")',
},
},
{
action: "press",
- selector: ".drive-heading button",
+ selector: '.drive-dialog button[aria-label="Close dialog"]',
key: "Shift",
},
],
diff --git a/web/workspace/test/drive-browser/drive.browser.test.ts b/web/workspace/test/drive-browser/drive.browser.test.ts
index 6b5d09f5f..ed007aa2c 100644
--- a/web/workspace/test/drive-browser/drive.browser.test.ts
+++ b/web/workspace/test/drive-browser/drive.browser.test.ts
@@ -5,10 +5,14 @@ import { chromium, type Page, type Route } from "playwright";
import { Buffer } from "node:buffer";
import type { FixtureLog } from "../../../../tools/web-ux/drive-fixture/api.ts";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../../..");
-const ready = (page: Page) => page.locator('[data-drive-ready="true"]').waitFor();
-const button = (page: Page, name: string) => page.getByRole("button", { name, exact: true });
-const draft = (page: Page) => page.getByRole("textbox", { name: /^Draft \(expected revision/ });
-const preview = (page: Page) => page.getByRole("article", { name: "Current saved content" });
+const ready = (page: Page) =>
+ page.locator('[data-drive-ready="true"]').waitFor();
+const button = (page: Page, name: string) =>
+ page.getByRole("button", { name, exact: true });
+const draft = (page: Page) =>
+ page.getByRole("textbox", { name: /^Draft \(expected revision/ });
+const preview = (page: Page) =>
+ page.getByRole("article", { name: "Current saved content" });
function gate() {
let release!: () => void;
const promise = new Promise
((resolve) => release = resolve);
@@ -180,7 +184,9 @@ Deno.test("production Drive two tabs retain losing CAS draft and never retry wit
await draft(second).inputValue(),
"Second actor unsaved draft",
);
- const casWrites = (await logs(origin)).filter((x) => x.path === "/mutate");
+ const casWrites = (await logs(origin)).filter((x) =>
+ x.path === "/mutate"
+ );
assertEquals(casWrites.map((x) => [x.nodeId, x.expectedRevision]), [[
"3",
"1",
@@ -235,7 +241,7 @@ Deno.test("production Drive delayed old read cannot replace another file or work
await ready(page);
assert((await preview(page).textContent())!.includes("Second document"));
assertEquals(
- await page.locator("main .drive-url a").getAttribute("href"),
+ await page.locator(".drive-details dd a").getAttribute("href"),
"/w/home-member/drive/4",
);
} finally {
@@ -321,7 +327,9 @@ Deno.test("production Drive delayed old save and status preserve destination dra
await button(page, "Save").click();
await page.getByText("Published — DB commit confirmed", { exact: false })
.last().waitFor();
- const pinnedWrites = (await logs(origin)).filter((x) => x.path === "/mutate");
+ const pinnedWrites = (await logs(origin)).filter((x) =>
+ x.path === "/mutate"
+ );
assertEquals(pinnedWrites.map((x) => [x.nodeId, x.expectedRevision]), [
["3", "1"],
["4", "1"],
@@ -342,12 +350,15 @@ Deno.test("production Drive rename move delete recreate preserve stable identity
try {
await page.goto(`${origin}/w/home-owner/drive/3`);
await ready(page);
+ await page.getByLabel("More actions", { exact: true }).click();
await button(page, "Rename / move").click();
await page.getByRole("textbox", { name: "Name", exact: true }).fill(
"renamed.md",
);
- await page.getByRole("textbox", { name: "Destination folder node ID" })
- .fill("2");
+ await page.getByRole("dialog").getByRole("button", {
+ name: "資料",
+ exact: true,
+ }).click();
await button(page, "Apply rename / move").click();
await page.getByRole("heading", { name: "renamed.md", exact: true })
.waitFor();
@@ -361,6 +372,7 @@ Deno.test("production Drive rename move delete recreate preserve stable identity
await ready(page);
await page.getByRole("link", { name: "renamed.md", exact: true }).click();
await ready(page);
+ await page.getByLabel("More actions", { exact: true }).click();
await button(page, "Delete").click();
await page.getByRole("alert").filter({ hasText: /not[_ ]found/i })
.waitFor();
@@ -368,6 +380,8 @@ Deno.test("production Drive rename move delete recreate preserve stable identity
await rootFolder(page);
await page.getByRole("link", { name: "資料", exact: true }).click();
await ready(page);
+ await page.locator(".drive-menu > summary").filter({ hasText: /^New$/ })
+ .click();
await button(page, "New Markdown").click();
await page.getByRole("textbox", { name: "Name", exact: true }).fill(
"renamed.md",
@@ -445,6 +459,7 @@ Deno.test("production Drive upload response loss and navigation cancellation que
await transmitted.promise;
await button(page, "Cancel transfer / check outcome").click();
await button(page, "Check request result").waitFor();
+ await button(page, "Cancel form").click();
await page.getByRole("link", { name: "資料", exact: true }).click();
await page.getByRole("heading", { name: "資料", exact: true }).waitFor();
await ready(page);
@@ -607,20 +622,26 @@ Deno.test("production Drive denial empty pages truncation unsafe content and ima
await page.getByText("This file type is download-only.", { exact: false })
.waitFor();
assertEquals(
- await page.locator("main svg, main iframe, main object").count(),
+ await page.locator(
+ "main svg:not([aria-hidden]), main iframe, main object",
+ ).count(),
0,
);
await page.goto(`${origin}/w/home-owner/drive/5`);
await ready(page);
await page.waitForFunction(() => {
const image = document.querySelector("main .drive-image");
- return image instanceof HTMLImageElement && image.complete && image.naturalWidth === 1200 &&
+ return image instanceof HTMLImageElement && image.complete &&
+ image.naturalWidth === 1200 &&
image.naturalHeight === 800;
});
assertEquals(
- await page.getByRole("img", { name: "landscape.png", exact: true }).evaluate((image) =>
- image instanceof HTMLImageElement ? [image.naturalWidth, image.naturalHeight] : []
- ),
+ await page.getByRole("img", { name: "landscape.png", exact: true })
+ .evaluate((image) =>
+ image instanceof HTMLImageElement
+ ? [image.naturalWidth, image.naturalHeight]
+ : []
+ ),
[1200, 800],
);
await page.goto(`${origin}/w/home-owner/drive/7`);
@@ -713,3 +734,154 @@ Deno.test("production Drive Markdown safe image placeholders preserve each image
await page.close();
}
}));
+
+Deno.test("production Drive menus dialogs folder picker and search remain usable at every viewport", () =>
+ harness(async (origin, browser) => {
+ for (const colorScheme of ["light", "dark"] as const) {
+ for (const width of [1440, 768, 390, 320]) {
+ const context = await browser.newContext({
+ viewport: { width, height: 900 },
+ colorScheme,
+ reducedMotion: "reduce",
+ });
+ const page = await context.newPage();
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(String(error)));
+ const capture = async (surface: string) => {
+ const geometry = await page.evaluate(() => {
+ const main = document.querySelector("main")!;
+ const dialog = document.querySelector("dialog[open]");
+ return {
+ viewport: innerWidth,
+ document: document.documentElement.scrollWidth,
+ main: main.clientWidth,
+ mainScroll: main.scrollWidth,
+ dialog: dialog?.clientWidth,
+ dialogScroll: dialog?.scrollWidth,
+ };
+ });
+ assert(geometry.document <= width, JSON.stringify(geometry));
+ assert(
+ geometry.mainScroll <= geometry.main + 1,
+ JSON.stringify(geometry),
+ );
+ if (geometry.dialog) {
+ assert(
+ geometry.dialogScroll! <= geometry.dialog + 1,
+ JSON.stringify(geometry),
+ );
+ }
+ await page.screenshot({
+ path: join(
+ root,
+ `web/workspace/.svelte-kit/drive-redesign/${surface}-${colorScheme}-${width}.png`,
+ ),
+ });
+ };
+ try {
+ const reset = await fetch(`${origin}/__fixture/reset`, {
+ method: "POST",
+ });
+ await reset.body?.cancel();
+ await page.goto(`${origin}/w/home-owner/drive`);
+ await ready(page);
+ const newMenu = page.locator(".drive-menu > summary").filter({
+ hasText: /^New$/,
+ });
+ assertEquals(await button(page, "New folder").isVisible(), false);
+ await capture("files");
+ await newMenu.press("Enter");
+ await button(page, "New folder").waitFor();
+ const menuBox = await page.locator(
+ ".drive-create-menu .drive-menu-panel",
+ ).boundingBox();
+ assert(
+ menuBox && menuBox.x >= 0 && menuBox.x + menuBox.width <= width,
+ JSON.stringify(menuBox),
+ );
+ await capture("menu");
+ await button(page, "New folder").click();
+ const dialog = page.getByRole("dialog", {
+ name: "New folder",
+ exact: true,
+ });
+ await dialog.waitFor();
+ assert(
+ await dialog.getByLabel("Name", { exact: true }).evaluate((el) =>
+ el === document.activeElement
+ ),
+ );
+ await capture("create");
+ for (let i = 0; i < 8; i++) {
+ await page.keyboard.press("Tab");
+ // Native dialogs may cycle through browser chrome (reported as body),
+ // but background page controls must stay inert.
+ assert(
+ await dialog.evaluate((el) =>
+ el.contains(document.activeElement) ||
+ document.activeElement === document.body
+ ),
+ "Modal must not focus background controls",
+ );
+ }
+ await page.keyboard.press("Escape");
+ await dialog.waitFor({ state: "hidden" });
+ await newMenu.locator(":scope:focus").waitFor();
+ await newMenu.press("Enter");
+ await button(page, "New folder").focus();
+ await page.keyboard.press("Escape");
+ assertEquals(await button(page, "New folder").isVisible(), false);
+ await newMenu.locator(":scope:focus").waitFor();
+ await newMenu.press("Enter");
+ await button(page, "New folder").click();
+ const folderName = `Created ${colorScheme} ${width}`;
+ await dialog.getByLabel("Name", { exact: true }).fill(folderName);
+ await button(page, "Create").click();
+ await dialog.waitFor({ state: "hidden" });
+ await page.getByRole("link", { name: folderName, exact: true })
+ .waitFor();
+ await page.getByRole("searchbox", { name: "Search Drive" }).fill(
+ folderName,
+ );
+ await button(page, "Search").click();
+ await button(page, "Show folder").waitFor();
+ await ready(page);
+ assertEquals(await page.locator(".drive-table tbody tr").count(), 1);
+ await button(page, "Show folder").click();
+ await ready(page);
+ await page.getByRole("link", { name: "README.md", exact: true })
+ .click();
+ await page.waitForURL("**/w/home-owner/drive/3");
+ await ready(page);
+ await preview(page).waitFor();
+ await capture("preview");
+ const more = page.getByLabel("More actions", { exact: true });
+ await more.click();
+ await button(page, "Rename / move").click();
+ const move = page.getByRole("dialog", {
+ name: "Rename / move",
+ exact: true,
+ });
+ await move.getByRole("button", { name: "資料", exact: true }).click();
+ await page.locator(".drive-folder-location strong").filter({
+ hasText: "資料",
+ }).waitFor();
+ await capture("move");
+ await button(page, "Parent folder").click();
+ await move.getByRole("button", { name: "資料", exact: true })
+ .waitFor();
+ assertEquals(
+ await move.getByRole("button", { name: "README.md", exact: true })
+ .count(),
+ 0,
+ );
+ await page.keyboard.press("Escape");
+ await move.waitFor({ state: "hidden" });
+ await more.locator(":scope:focus").waitFor();
+ assertEquals(errors, []);
+ } finally {
+ await context.close();
+ }
+ }
+ }
+ }));