From 674644714cb02bd766242688ed35ad67c088fb87 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Sat, 22 Aug 2026 10:07:11 +0000 Subject: [PATCH] feat(mcp): style the upload apps' file picker The Upload to IPFS / Upload to Vault apps dropped the native into the grid, which renders the browser's unthemed "Choose file/No file chosen" chrome and made it hard to tell the file field from the submit button. Replace the bare native input with a styled composite picker: the native input is pinned invisible under a themed "Choose file" button plus the picked-file label (file-field/file-picker/file-btn/file-name utilities in the shared inline theme). Both upload bootstraps now wire a change listener that reflects the picked file's name in the label, so what was selected is always visible before submitting. Clicking anywhere on the styled field still opens the native picker; the submit (Upload) button is unchanged and now reads clearly against the distinct file chrome. Coverage: extend the sunpeak browser inspector suite with upload/vault file-picker tests that assert the chrome renders and that selecting a file updates the label across the ChatGPT and Claude hosts. --- internal/mcpapp/css/input.css | 21 +++++++++++ internal/mcpapp/ipfs_upload_app.templ | 8 ++++- internal/mcpapp/vault_upload_app.templ | 8 ++++- packages/apps/src/entries/ipfs-upload.ts | 1 + packages/apps/src/entries/vault-upload.ts | 1 + packages/apps/src/ipfs-upload-bootstrap.ts | 16 +++++++-- packages/apps/src/vault-upload-bootstrap.ts | 16 +++++++-- tests/sunpeak/tests/inspector-apps.test.ts | 40 +++++++++++++++++++++ 8 files changed, 105 insertions(+), 6 deletions(-) diff --git a/internal/mcpapp/css/input.css b/internal/mcpapp/css/input.css index 003ec612..c7f0391a 100644 --- a/internal/mcpapp/css/input.css +++ b/internal/mcpapp/css/input.css @@ -107,6 +107,27 @@ body { @apply w-full px-2.5 py-2 border border-border rounded-md bg-background-deep text-foreground font-sans placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-accent; } +/* ---- File picker (MCP upload apps) --------------------------------------- */ +/* The native cannot be styled, so pin it invisible atop a + styled chrome (a "Choose file" button + the picked name). Clicking anywhere + on the field opens the native picker; the name is refreshed from JS on + change. */ +@utility file-field { + @apply relative cursor-pointer; +} +@utility file-input { + @apply absolute inset-0 w-full h-full opacity-0 cursor-pointer; +} +@utility file-picker { + @apply flex items-center gap-2 px-2.5 py-2 border border-border rounded-md bg-background-deep; +} +@utility file-btn { + @apply shrink-0 inline-flex items-center px-3 py-1 rounded border border-border bg-card text-foreground text-sm font-medium cursor-pointer select-none transition-colors hover:bg-card/80; +} +@utility file-name { + @apply truncate min-w-0 text-sm text-muted-foreground; +} + /* ---- Tables (MCP pin-list + vault) --------------------------------------- */ @utility table { @apply w-full text-sm text-foreground; diff --git a/internal/mcpapp/ipfs_upload_app.templ b/internal/mcpapp/ipfs_upload_app.templ index a4fa7937..12bea4a5 100644 --- a/internal/mcpapp/ipfs_upload_app.templ +++ b/internal/mcpapp/ipfs_upload_app.templ @@ -10,7 +10,13 @@ templ IPFSUploadAppForm() {

Upload to IPFS

- +
+ +
+ Choose file + No file chosen +
+
diff --git a/internal/mcpapp/vault_upload_app.templ b/internal/mcpapp/vault_upload_app.templ index 5379aaf5..fd90f586 100644 --- a/internal/mcpapp/vault_upload_app.templ +++ b/internal/mcpapp/vault_upload_app.templ @@ -10,7 +10,13 @@ templ VaultUploadAppForm() {

Upload to Vault

- +
+ +
+ Choose file + No file chosen +
+
diff --git a/packages/apps/src/entries/ipfs-upload.ts b/packages/apps/src/entries/ipfs-upload.ts index e3ef4cbf..42363063 100644 --- a/packages/apps/src/entries/ipfs-upload.ts +++ b/packages/apps/src/entries/ipfs-upload.ts @@ -26,6 +26,7 @@ export const def: IPFSUploadAppEntry = { ids: { form: "ipfs-upload-form", file: "file", + fileName: "file-name", name: "name", status: "ipfs-upload-status", outCid: "out-cid", diff --git a/packages/apps/src/entries/vault-upload.ts b/packages/apps/src/entries/vault-upload.ts index 8014cafe..08932cdd 100644 --- a/packages/apps/src/entries/vault-upload.ts +++ b/packages/apps/src/entries/vault-upload.ts @@ -22,6 +22,7 @@ export const def: VaultUploadAppEntry = { ids: { form: "vault-upload-form", file: "vfile", + fileName: "vfile-name", vaultPath: "vault-path", status: "vault-upload-status", outPath: "out-path", diff --git a/packages/apps/src/ipfs-upload-bootstrap.ts b/packages/apps/src/ipfs-upload-bootstrap.ts index 9828194c..8988256a 100644 --- a/packages/apps/src/ipfs-upload-bootstrap.ts +++ b/packages/apps/src/ipfs-upload-bootstrap.ts @@ -4,7 +4,8 @@ // // Element contract: // #ipfs-upload-form the that starts the upload. -// #file the file . +// #file the file (styled composite picker). +// #file-name the picked-file label span ("No file chosen"). // #name the upload-name . // #ipfs-upload-status the status element (class "status "). // #out-cid the result CID . @@ -38,6 +39,7 @@ import { byId, setStatus, StatusClass } from "@/dom"; export type IPFSUploadElementIds = { form: string; file: string; + fileName: string; name: string; status: string; outCid: string; @@ -49,7 +51,8 @@ export type IPFSUploadAppEntry = AppDefinition void): void }; - fileInput: { files: FileList | null }; + fileInput: HTMLInputElement; + fileNameEl: HTMLElement; nameInput: { value: string }; statusEl: HTMLElement; outCid: HTMLElement; @@ -135,6 +138,14 @@ export function runIPFSUploadEntry(opts: IPFSUploadEntryOptions) { if (r.setCid) opts.elements.outCid.textContent = ctx.outCid || opts.elements.outCid.textContent; }); + // Reflect the picked file's name in the styled picker chrome. The native + // input is invisible (see file-field/file-input in the theme), so without + // this the user has no way to see what was selected. + opts.elements.fileInput.addEventListener("change", () => { + const picked = opts.elements.fileInput.files?.[0]; + opts.elements.fileNameEl.textContent = picked ? picked.name : "No file chosen"; + }); + const submit = (file: File | null, name: string) => { if (!file) { setStatus(opts.elements.statusEl, StatusClass.Error, opts.config.noFileMsg); @@ -176,6 +187,7 @@ export function mountIPFSUploadApp(def: IPFSUploadAppEntry, root: Document, call elements: { form: byId(root, def.ids.form)!, fileInput: byId(root, def.ids.file)!, + fileNameEl: byId(root, def.ids.fileName)!, nameInput: byId(root, def.ids.name)!, statusEl: statusEl!, outCid: byId(root, def.ids.outCid)!, diff --git a/packages/apps/src/vault-upload-bootstrap.ts b/packages/apps/src/vault-upload-bootstrap.ts index 93c8875f..7338355a 100644 --- a/packages/apps/src/vault-upload-bootstrap.ts +++ b/packages/apps/src/vault-upload-bootstrap.ts @@ -5,7 +5,8 @@ // // Element contract: // #vault-upload-form the that submits the upload. -// #vfile the file . +// #vfile the file (styled composite picker). +// #vfile-name the picked-file label span ("No file chosen"). // #vault-path the vault destination path . // #vault-upload-status the status element (class "status "). // #out-path the result stored-path . @@ -31,6 +32,7 @@ import { byId, setStatus, StatusClass } from "@/dom"; export type VaultUploadElementIds = { form: string; file: string; + fileName: string; vaultPath: string; status: string; outPath: string; @@ -42,7 +44,8 @@ export type VaultUploadAppEntry = AppDefinition void): void }; - fileInput: { files: FileList | null }; + fileInput: HTMLInputElement; + fileNameEl: HTMLElement; vaultPathInput: { value: string }; statusEl: HTMLElement; outPath: HTMLElement; @@ -133,6 +136,14 @@ export function runVaultUploadEntry(opts: VaultUploadEntryOptions) { if (r.setOutPath && ctx.outPath) opts.elements.outPath.textContent = ctx.outPath; }); + // Reflect the picked file's name in the styled picker chrome. The native + // input is invisible (see file-field/file-input in the theme), so without + // this the user has no way to see what was selected. + opts.elements.fileInput.addEventListener("change", () => { + const picked = opts.elements.fileInput.files?.[0]; + opts.elements.fileNameEl.textContent = picked ? picked.name : "No file chosen"; + }); + const submit = async (file: File | null, vaultPath: string) => { if (!file) { setStatus(opts.elements.statusEl, StatusClass.Error, opts.config.noFileMsg); @@ -179,6 +190,7 @@ export function mountVaultUploadApp(def: VaultUploadAppEntry, root: Document, ca elements: { form: byId(root, def.ids.form)!, fileInput: byId(root, def.ids.file)!, + fileNameEl: byId(root, def.ids.fileName)!, vaultPathInput: byId(root, def.ids.vaultPath)!, statusEl: statusEl!, outPath: byId(root, def.ids.outPath)!, diff --git a/tests/sunpeak/tests/inspector-apps.test.ts b/tests/sunpeak/tests/inspector-apps.test.ts index 3b276216..1496ce9c 100644 --- a/tests/sunpeak/tests/inspector-apps.test.ts +++ b/tests/sunpeak/tests/inspector-apps.test.ts @@ -82,3 +82,43 @@ test('upload_file app boots (ipfs-upload bundle has no unmet imports)', async ({ test('vault_put_file app boots (vault-upload bundle has no unmet imports)', async ({ inspector }) => { await assertUploadAppBoots('vault_put_file', 'Upload to Vault', inspector); }); + +/** + * The upload forms use a styled composite file picker: the native + * is pinned invisible under a "Choose file" button + a + * picked-file label, because the browser's native "Choose file/No file chosen" + * chrome cannot be themed. Verify the chrome renders and that picking a file + * updates the label (the bootstraps wire a change listener for that). + */ +async function assertStyledFilePicker( + tool: string, + fileId: string, + labelId: string, + inspector: { renderTool: (n: string, i: unknown) => Promise<{ app(): FrameLocator }> }, +) { + const result = await inspector.renderTool(tool, {}); + const app = result.app(); + const body = await app.locator('body').innerText(); + expect(body).toContain('Choose file'); + expect(body).toContain('No file chosen'); + expect(body).toContain('Upload'); + + // Actually select a file through the hidden native input and assert the + // picker label adopts the filename (proves the change listener fired and + // the machine is wired, not just the static HTML shell). + const fileInput = app.locator(`#${fileId}`); + await fileInput.setInputFiles({ + name: 'report.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('pdf-bytes'), + }); + await expect(app.locator(`#${labelId}`)).toHaveText('report.pdf'); +} + +test('upload_file app renders the styled file picker and reflects the picked name', async ({ inspector }) => { + await assertStyledFilePicker('upload_file', 'file', 'file-name', inspector); +}); + +test('vault_put_file app renders the styled file picker and reflects the picked name', async ({ inspector }) => { + await assertStyledFilePicker('vault_put_file', 'vfile', 'vfile-name', inspector); +});