Browse Source

Merge branch 'frontend-format' of bajdev/mpckit into main

bajdev 1 hour ago
parent
commit
7c195492e0

+ 3 - 0
frontend/.prettierignore

@@ -0,0 +1,3 @@
+dist
+bindings
+node_modules

+ 7 - 0
frontend/.prettierrc

@@ -0,0 +1,7 @@
+{
+  "printWidth": 120,
+  "semi": true,
+  "singleQuote": false,
+  "trailingComma": "all",
+  "plugins": ["prettier-plugin-svelte"]
+}

+ 1 - 1
frontend/index.html

@@ -1,4 +1,4 @@
-<!DOCTYPE html>
+<!doctype html>
 <html lang="en">
   <head>
     <meta charset="UTF-8" />

+ 32 - 0
frontend/package-lock.json

@@ -15,6 +15,8 @@
       },
       "devDependencies": {
         "@sveltejs/vite-plugin-svelte": "^7.0.0",
+        "prettier": "^3.9.10",
+        "prettier-plugin-svelte": "^4.1.1",
         "svelte": "^5.46.4",
         "svelte-check": "^4.4.8",
         "tslib": "^2.6.2",
@@ -978,6 +980,36 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/prettier": {
+      "version": "3.9.10",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz",
+      "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "prettier": "bin/prettier.cjs"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
+    "node_modules/prettier-plugin-svelte": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz",
+      "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "peerDependencies": {
+        "prettier": "^3.0.0",
+        "svelte": "^5.0.0"
+      }
+    },
     "node_modules/readdirp": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",

+ 5 - 1
frontend/package.json

@@ -8,7 +8,9 @@
     "build:dev": "vite build --minify false --mode development",
     "build": "vite build --mode production",
     "preview": "vite preview",
-    "check": "svelte-check --tsconfig ./tsconfig.json"
+    "check": "svelte-check --tsconfig ./tsconfig.json",
+    "format": "prettier --write .",
+    "format:check": "prettier --check ."
   },
   "dependencies": {
     "@fontsource/ibm-plex-mono": "^5.3.0",
@@ -18,6 +20,8 @@
   },
   "devDependencies": {
     "@sveltejs/vite-plugin-svelte": "^7.0.0",
+    "prettier": "^3.9.10",
+    "prettier-plugin-svelte": "^4.1.1",
     "svelte": "^5.46.4",
     "svelte-check": "^4.4.8",
     "tslib": "^2.6.2",

+ 66 - 12
frontend/src/App.svelte

@@ -10,7 +10,19 @@
   import { transfer, transferKind } from "./lib/pads";
   import SequencePanel from "./lib/SequencePanel.svelte";
   import SlotGrid, { type Cell } from "./lib/SlotGrid.svelte";
-  import { app, exportFolder, exportZip, listen, loadProject, message, newProject, refresh, run, ui, type Tab } from "./lib/store.svelte";
+  import {
+    app,
+    exportFolder,
+    exportZip,
+    listen,
+    loadProject,
+    message,
+    newProject,
+    refresh,
+    run,
+    ui,
+    type Tab,
+  } from "./lib/store.svelte";
   import { BANKS, PER_BANK, bars, gridSlots, tuneText } from "./lib/slots";
 
   onMount(() => {
@@ -35,7 +47,11 @@
 
   // Export would fail on a missing sample file, so it is not offered at all.
   const exportBlocked = $derived((st?.missingCount ?? 0) > 0);
-  const exportBlockedWhy = $derived(exportBlocked ? "Export is disabled while sample files are missing. Replace them, or put the files back and load the project again." : undefined);
+  const exportBlockedWhy = $derived(
+    exportBlocked
+      ? "Export is disabled while sample files are missing. Replace them, or put the files back and load the project again."
+      : undefined,
+  );
 
   const padCounts = $derived(perBank((s) => (pads[s]?.file ?? "") !== ""));
   const seqCounts = $derived(perBank((s) => seqBySlot.has(s)));
@@ -46,7 +62,9 @@
           const p = pads[slot];
           const badges = p.missing
             ? ["missing"]
-            : [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(Boolean);
+            : [tuneText(p.coarse, p.fine), p.muteGroup ? "M" + p.muteGroup : "", p.hasNotes ? "notes" : ""].filter(
+                Boolean,
+              );
           return {
             slot,
             label: p.label,
@@ -119,13 +137,24 @@
     <h1 class="visually-hidden">MPCkit</h1>
     <div class="name">
       <label class="eyebrow" for="project-name">Project name</label>
-      <input id="project-name" type="text" value={st?.name ?? ""} spellcheck="false" autocomplete="off" onchange={rename} />
+      <input
+        id="project-name"
+        type="text"
+        value={st?.name ?? ""}
+        spellcheck="false"
+        autocomplete="off"
+        onchange={rename}
+      />
     </div>
     <div class="actions">
       <button type="button" class="btn" onclick={newProject}>New</button>
       <button type="button" class="btn" onclick={loadProject}>Load</button>
-      <button type="button" class="btn" disabled={exportBlocked} title={exportBlockedWhy} onclick={exportZip}>Export zip</button>
-      <button type="button" class="btn primary" disabled={exportBlocked} title={exportBlockedWhy} onclick={exportFolder}>Export to folder</button>
+      <button type="button" class="btn" disabled={exportBlocked} title={exportBlockedWhy} onclick={exportZip}
+        >Export zip</button
+      >
+      <button type="button" class="btn primary" disabled={exportBlocked} title={exportBlockedWhy} onclick={exportFolder}
+        >Export to folder</button
+      >
     </div>
     <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
   </header>
@@ -138,8 +167,26 @@
   {/if}
 
   <div class="tabs" role="tablist" aria-label="Views">
-    <button id="tab-pads" type="button" role="tab" aria-selected={ui.tab === "pads"} aria-controls="view-pads" tabindex={ui.tab === "pads" ? 0 : -1} onclick={() => pickTab("pads")} onkeydown={tabKey}>Pads</button>
-    <button id="tab-sequences" type="button" role="tab" aria-selected={ui.tab === "sequences"} aria-controls="view-sequences" tabindex={ui.tab === "sequences" ? 0 : -1} onclick={() => pickTab("sequences")} onkeydown={tabKey}>Sequences</button>
+    <button
+      id="tab-pads"
+      type="button"
+      role="tab"
+      aria-selected={ui.tab === "pads"}
+      aria-controls="view-pads"
+      tabindex={ui.tab === "pads" ? 0 : -1}
+      onclick={() => pickTab("pads")}
+      onkeydown={tabKey}>Pads</button
+    >
+    <button
+      id="tab-sequences"
+      type="button"
+      role="tab"
+      aria-selected={ui.tab === "sequences"}
+      aria-controls="view-sequences"
+      tabindex={ui.tab === "sequences" ? 0 : -1}
+      onclick={() => pickTab("sequences")}
+      onkeydown={tabKey}>Sequences</button
+    >
   </div>
 
   {#if st}
@@ -161,7 +208,10 @@
             ondragbank={(b) => (ui.padBank = b)}
           />
           <SlotGrid cells={padCells} selected={ui.padSlot} onpick={pickPad} dropTarget ondroppad={dropPad} />
-          <p class="hint grid-hint">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drag over a bank letter to reach another bank. Drop a WAV file on any pad to assign it.</p>
+          <p class="hint grid-hint">
+            Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while
+            dragging to copy. Drag over a bank letter to reach another bank. Drop a WAV file on any pad to assign it.
+          </p>
         </section>
         <PadPanel pad={pads[ui.padSlot]} limits={st.limits} />
       </div>
@@ -182,7 +232,9 @@
             }}
           />
           <SlotGrid cells={seqCells} selected={ui.seqSlot} onpick={(s) => (ui.seqSlot = s)} />
-          <p class="hint grid-hint">Each pad is one sequence slot, {st.limits.maxSequences} in total. Click an empty slot to add a sequence there.</p>
+          <p class="hint grid-hint">
+            Each pad is one sequence slot, {st.limits.maxSequences} in total. Click an empty slot to add a sequence there.
+          </p>
         </section>
         <SequencePanel slot={ui.seqSlot} seq={seqBySlot.get(ui.seqSlot)} limits={st.limits} />
       </div>
@@ -192,7 +244,9 @@
       <span>{st.sampleCount} of {st.limits.maxSamples} samples</span>
       <span>{st.sequenceCount} of {st.limits.maxSequences} sequences</span>
       <span>WAV files only</span>
-      {#if st.missingCount}<span class="missing">{st.missingCount} sample {st.missingCount === 1 ? "file" : "files"} missing: export is disabled</span>{/if}
+      {#if st.missingCount}<span class="missing"
+          >{st.missingCount} sample {st.missingCount === 1 ? "file" : "files"} missing: export is disabled</span
+        >{/if}
       {#if st.dirty}<span class="dirty">Unsaved changes: export to keep them</span>{/if}
       {#if app.notice}<span class="notice" title={app.notice}>{app.notice}</span>{/if}
     </footer>
@@ -204,7 +258,7 @@
     <AboutModal onclose={() => (ui.aboutOpen = false)} />
   {/if}
   {#if ui.picker && st}
-    <PadPicker picker={ui.picker} pads={pads} onclose={() => (ui.picker = null)} />
+    <PadPicker picker={ui.picker} {pads} onclose={() => (ui.picker = null)} />
   {/if}
   {#if ui.confirm}
     <ConfirmModal c={ui.confirm} />

+ 3 - 1
frontend/src/lib/AboutModal.svelte

@@ -29,7 +29,9 @@
 <div class="backdrop" onclick={(e) => e.target === e.currentTarget && onclose()}>
   <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="about-title">
     <h2 id="about-title">About MPCkit</h2>
-    <p class="lead">A desktop project and kit editor for the MPC Sample. Build kits on your computer, then export them to the device.</p>
+    <p class="lead">
+      A desktop project and kit editor for the MPC Sample. Build kits on your computer, then export them to the device.
+    </p>
     <div class="facts">
       <span>Version {about?.version ?? ""}</span>
       {#if about?.commit}<span>Build {about.commit}</span>{/if}

+ 10 - 2
frontend/src/lib/ConfirmModal.svelte

@@ -17,7 +17,13 @@
 <svelte:window onkeydown={keydown} />
 
 <div class="backdrop">
-  <div class="dialog" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message">
+  <div
+    class="dialog"
+    role="alertdialog"
+    aria-modal="true"
+    aria-labelledby="confirm-title"
+    aria-describedby="confirm-message"
+  >
     <h2 id="confirm-title">{c.title}</h2>
     <p id="confirm-message">{c.message}</p>
     {#if c.items.length}
@@ -28,7 +34,9 @@
       </ul>
     {/if}
     <div class="buttons">
-      <button type="button" class="btn secondary" bind:this={cancelButton} onclick={() => c.resolve(false)}>Cancel</button>
+      <button type="button" class="btn secondary" bind:this={cancelButton} onclick={() => c.resolve(false)}
+        >Cancel</button
+      >
       <button type="button" class="btn primary" onclick={() => c.resolve(true)}>{c.action}</button>
     </div>
   </div>

+ 25 - 6
frontend/src/lib/PadPanel.svelte

@@ -43,7 +43,9 @@
   {#if pad.file === ""}
     <div class="empty-box drop" data-file-drop-target data-slot={pad.slot}>
       <p>This pad is empty. Drop a WAV file on it, or choose one.</p>
-      <button type="button" class="btn primary" onclick={() => run(() => Service.AssignSampleDialog(pad.slot))}>Choose WAV file</button>
+      <button type="button" class="btn primary" onclick={() => run(() => Service.AssignSampleDialog(pad.slot))}
+        >Choose WAV file</button
+      >
     </div>
   {:else}
     <div class="stack">
@@ -56,10 +58,21 @@
       {/if}
       <div class="row">
         <button type="button" class="btn primary" disabled={pad.missing} onclick={() => preview(pad)}>
-          <svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 2 L12 7 L3 12 Z" /></svg>
+          <svg
+            aria-hidden="true"
+            width="14"
+            height="14"
+            viewBox="0 0 14 14"
+            fill="none"
+            stroke="currentColor"
+            stroke-width="2"
+            stroke-linejoin="round"><path d="M3 2 L12 7 L3 12 Z" /></svg
+          >
           Play
         </button>
-        <button type="button" class="btn secondary" onclick={() => run(() => Service.AssignSampleDialog(pad.slot))}>Replace</button>
+        <button type="button" class="btn secondary" onclick={() => run(() => Service.AssignSampleDialog(pad.slot))}
+          >Replace</button
+        >
         <button type="button" class="btn secondary" onclick={() => run(() => Service.ClearPad(pad.slot))}>Clear</button>
       </div>
 
@@ -127,11 +140,17 @@
       </div>
 
       <div class="row divided">
-        <button type="button" class="btn secondary" onclick={() => (ui.picker = { kind: "move", from: pad.slot })}>Move to pad</button>
-        <button type="button" class="btn secondary" onclick={() => (ui.picker = { kind: "copy", from: pad.slot })}>Copy to pad</button>
+        <button type="button" class="btn secondary" onclick={() => (ui.picker = { kind: "move", from: pad.slot })}
+          >Move to pad</button
+        >
+        <button type="button" class="btn secondary" onclick={() => (ui.picker = { kind: "copy", from: pad.slot })}
+          >Copy to pad</button
+        >
       </div>
       {#if pad.hasNotes}
-        <p class="hint notes">Recorded notes in a sequence play this pad. They stay on {pad.label} if the sample moves.</p>
+        <p class="hint notes">
+          Recorded notes in a sequence play this pad. They stay on {pad.label} if the sample moves.
+        </p>
       {/if}
     </div>
   {/if}

+ 12 - 2
frontend/src/lib/PadPicker.svelte

@@ -25,7 +25,15 @@
   const target = $derived(bank * PER_BANK + pad);
   const kind = $derived(transferKind(src, pads[target], picker.kind === "copy"));
   const action = $derived(
-    target === picker.from ? "" : kind === "move" ? "Move" : kind === "swap" ? "Swap" : pads[target].file ? "Copy and replace" : "Copy",
+    target === picker.from
+      ? ""
+      : kind === "move"
+        ? "Move"
+        : kind === "swap"
+          ? "Swap"
+          : pads[target].file
+            ? "Copy and replace"
+            : "Copy",
   );
 
   let firstField: HTMLSelectElement | undefined = $state();
@@ -87,7 +95,9 @@
     </p>
     <div class="buttons">
       <button type="button" class="btn secondary" onclick={onclose}>Cancel</button>
-      <button type="button" class="btn primary" disabled={!action} onclick={go}>{action || (picker.kind === "move" ? "Move" : "Copy")}</button>
+      <button type="button" class="btn primary" disabled={!action} onclick={go}
+        >{action || (picker.kind === "move" ? "Move" : "Copy")}</button
+      >
     </div>
   </div>
 </div>

+ 47 - 14
frontend/src/lib/SequencePanel.svelte

@@ -17,15 +17,16 @@
 
   async function save() {
     if (!seq) return;
-    const s = seq.slot;
-    const n = name;
-    const b = Number(bpm);
-    const l = Number(bars);
-    if (n === seq.name && b === seq.bpm && l === seq.bars) return;
-    if (seq.hasNotes && l < seq.bars) {
+    // Copied before any await: props are live and may change meanwhile.
+    const slot = seq.slot;
+    const newName = name;
+    const newBpm = Number(bpm);
+    const newBars = Number(bars);
+    if (newName === seq.name && newBpm === seq.bpm && newBars === seq.bars) return;
+    if (seq.hasNotes && newBars < seq.bars) {
       const ok = await confirm({
         title: "Shorten a sequence with notes?",
-        message: `${seq.name} has recorded notes. Notes after bar ${l} will not play, but they are kept: lengthening the sequence again brings them back.`,
+        message: `${seq.name} has recorded notes. Notes after bar ${newBars} will not play, but they are kept: lengthening the sequence again brings them back.`,
         items: [],
         action: "Shorten",
       });
@@ -34,10 +35,10 @@
         return;
       }
     }
-    await run(() => Service.SetSequence(s, n, b, l));
+    await run(() => Service.SetSequence(slot, newName, newBpm, newBars));
     // run() replaced the state; if Go refused, the fields still hold the
     // typed values, so put the stored ones back.
-    const now = app.state?.sequences?.find((q) => q.slot === s);
+    const now = app.state?.sequences?.find((q) => q.slot === slot);
     if (now) {
       name = now.name;
       bpm = now.bpm;
@@ -47,14 +48,14 @@
 
   async function remove() {
     if (!seq) return;
-    const s = seq.slot;
+    const slot = seq.slot; // copied before the await, as in save()
     const ok = await confirm({
       title: `Remove ${seq.name}?`,
       message: seq.hasNotes ? "Its recorded notes will be deleted too." : "The sequence has no recorded notes.",
       items: [],
       action: "Remove",
     });
-    if (ok) await run(() => Service.RemoveSequence(s));
+    if (ok) await run(() => Service.RemoveSequence(slot));
   }
 
   function enter(e: KeyboardEvent) {
@@ -85,16 +86,48 @@
     <div class="stack">
       <div class="control">
         <label class="label" for="seq-name">Name</label>
-        <input id="seq-name" class="field" type="text" spellcheck="false" autocomplete="off" value={name} oninput={(e) => (name = e.currentTarget.value)} onchange={save} onkeydown={enter} />
+        <input
+          id="seq-name"
+          class="field"
+          type="text"
+          spellcheck="false"
+          autocomplete="off"
+          value={name}
+          oninput={(e) => (name = e.currentTarget.value)}
+          onchange={save}
+          onkeydown={enter}
+        />
       </div>
       <div class="pair">
         <div class="control">
           <label class="label" for="seq-bpm">BPM ({limits.minBPM}–{limits.maxBPM})</label>
-          <input id="seq-bpm" class="field mono" type="number" min={limits.minBPM} max={limits.maxBPM} step="any" value={bpm} oninput={(e) => (bpm = e.currentTarget.valueAsNumber)} onchange={save} onkeydown={enter} />
+          <input
+            id="seq-bpm"
+            class="field mono"
+            type="number"
+            min={limits.minBPM}
+            max={limits.maxBPM}
+            step="any"
+            value={bpm}
+            oninput={(e) => (bpm = e.currentTarget.valueAsNumber)}
+            onchange={save}
+            onkeydown={enter}
+          />
         </div>
         <div class="control">
           <label class="label" for="seq-bars">Length (bars)</label>
-          <input id="seq-bars" class="field mono" type="number" min="1" max={limits.maxBars} step="1" value={bars} oninput={(e) => (bars = e.currentTarget.valueAsNumber)} onchange={save} onkeydown={enter} />
+          <input
+            id="seq-bars"
+            class="field mono"
+            type="number"
+            min="1"
+            max={limits.maxBars}
+            step="1"
+            value={bars}
+            oninput={(e) => (bars = e.currentTarget.valueAsNumber)}
+            onchange={save}
+            onkeydown={enter}
+          />
         </div>
       </div>
       <span class="hint">Notes are recorded on the device. This screen sets name, tempo and length only.</span>

+ 8 - 2
frontend/src/lib/pads.ts

@@ -40,7 +40,9 @@ async function doTransfer(kind: Transfer, from: number, to: number): Promise<voi
   const changed = kind === "copy" ? [dst] : [src, dst];
   for (const p of changed) {
     if (p.hasNotes) {
-      items.push(`${p.label} has recorded notes. They stay on ${p.label} and will play ${soundAfter(kind, p, src, dst)}.`);
+      items.push(
+        `${p.label} has recorded notes. They stay on ${p.label} and will play ${soundAfter(kind, p, src, dst)}.`,
+      );
     }
   }
   if (items.length) {
@@ -54,7 +56,11 @@ async function doTransfer(kind: Transfer, from: number, to: number): Promise<voi
   }
 
   const call =
-    kind === "move" ? () => Service.MovePad(from, to) : kind === "swap" ? () => Service.SwapPads(from, to) : () => Service.CopyPad(from, to);
+    kind === "move"
+      ? () => Service.MovePad(from, to)
+      : kind === "swap"
+        ? () => Service.SwapPads(from, to)
+        : () => Service.CopyPad(from, to);
   if (await run(call)) {
     ui.padBank = Math.floor(to / PER_BANK);
     ui.padSlot = to;

+ 8 - 2
frontend/src/lib/store.svelte.ts

@@ -177,9 +177,15 @@ async function exportWith(
 }
 
 export function exportFolder(): Promise<void> {
-  return exportWith(() => Service.ChooseExportFolder(), (overwrite) => Service.ExportFolder(overwrite));
+  return exportWith(
+    () => Service.ChooseExportFolder(),
+    (overwrite) => Service.ExportFolder(overwrite),
+  );
 }
 
 export function exportZip(): Promise<void> {
-  return exportWith(() => Service.ChooseZipPath(), (overwrite) => Service.ExportZip(overwrite));
+  return exportWith(
+    () => Service.ChooseZipPath(),
+    (overwrite) => Service.ExportZip(overwrite),
+  );
 }

+ 2 - 2
frontend/svelte.config.js

@@ -1,7 +1,7 @@
-import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
 
 export default {
   // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
   // for more information about preprocessors
   preprocess: vitePreprocess(),
-}
+};

+ 1 - 1
frontend/tsconfig.json

@@ -20,5 +20,5 @@
     "checkJs": true,
     "isolatedModules": true
   },
-  "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "bindings"],
+  "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "bindings"]
 }

+ 82 - 0
pull-requests/frontend-format/format-the-frontend-with-prettier.md

@@ -0,0 +1,82 @@
+# Format the frontend with Prettier
+
+Branch: `frontend-format` → `main`
+
+PR 11 of the review fixes plan (`docs/review-fixes-plan.md`), a readability PR: no behaviour change, no version change.
+
+## What changed
+
+The PR has three commits, so the large mechanical one can be skipped in review:
+
+1. **Add Prettier to the frontend (R2).**
+   - **Dev dependencies:** `prettier` 3.9.10 (published 2026-10-10) and `prettier-plugin-svelte` 4.1.1 (2026-06-15). Both are the current releases `min-release-age=7` allows, and the lockfile is updated: npm added only these two packages.
+   - **`frontend/.prettierrc`:** `printWidth` 120, double quotes, semicolons, trailing commas, and the Svelte plugin. Everything else is Prettier's default, which already matched the code: two-space indents, `<script>` and `<style>` content indented, blocks in script–markup–style order.
+   - **`frontend/.prettierignore`:** `dist`, `bindings` and `node_modules`.
+   - **npm scripts:** `format` (`prettier --write .`) and `format:check` (`prettier --check .`) in `frontend/package.json`.
+2. **Reformat the frontend with Prettier.**
+   - The output of `npm run format` and nothing else, across 11 files, all under `frontend/`.
+   - Besides `src`, Prettier touched three config files: `index.html` (`<!DOCTYPE html>` became `<!doctype html>`, which HTML treats the same), `svelte.config.js` (single quotes to double, semicolons added) and `tsconfig.json` (a trailing comma removed).
+3. **Name the variables in `SequencePanel.svelte`'s `save()` and `remove()` (R3).**
+   - `s`, `n`, `b` and `l` become `slot`, `newName`, `newBpm` and `newBars`.
+   - The local `slot` shadows the component's `slot` prop on purpose: it is copied from `seq.slot` before the `await`, because props are live. A comment now says so.
+
+**Long lines:** 30 lines under `frontend/src` were over 120 characters, the longest 250. Now 5 are, and Prettier cannot break any of them:
+
+| Line | Length | Why Prettier leaves it |
+| --- | --- | --- |
+| `src/lib/AboutModal.svelte:28` | 192 | A `svelte-ignore` comment, which must stay on one line. |
+| `src/lib/SequencePanel.svelte:28` | 159 | A template literal: the "Shorten a sequence with notes?" message. |
+| `src/App.svelte:73` | 143 | A template literal: a pad's screen-reader label. |
+| `src/App.svelte:52` | 124 | A string literal: the "Export is disabled…" tooltip. |
+| `src/App.svelte:236` | 122 | Text with a `{st.limits.maxSequences}` expression in the sequence grid's hint. |
+
+## Notes
+
+- **Whitespace that could show on screen.** I compiled every component with the Svelte compiler before and after the reformat and compared the output. The compiler keeps rendered whitespace and reprints the script code, so any difference is one the page could show.
+  - **The result:** 7 of 8 components compile to identical output. The one difference is in `App.svelte`: Prettier wrapped the pads tab's help paragraph ("Click a pad to select and hear it. … Drop a WAV file on any pad to assign it.") onto two lines. In the compiled template, that turns one space into a newline plus indentation inside the text.
+  - **Why it cannot show:** that paragraph (`.hint.grid-hint`) uses normal whitespace handling, which collapses it back to a single space. The only `white-space: pre-line` rule in the app is on the error banner.
+  - **No ignore comments added:** none was needed.
+  - **After the rename:** `SequencePanel.svelte` differs from the reformatted version only in the renamed variables and the two comments.
+- **Left out on purpose:** `add()` in `SequencePanel.svelte` also uses `const s = slot`. The plan names only `save()` and `remove()`, so I left `add()` as it is. Say if it should be renamed too.
+- **The checks list in the plan** already includes `npm run format:check` from this PR on.
+
+## Testing
+
+**On the server, every command with `GOTOOLCHAIN=go1.27.4`:**
+- `go test ./...` passes; no test changed.
+- `go vet ./...` and `staticcheck ./...` are clean, and `govulncheck ./...` reports "No vulnerabilities found."
+- In `frontend`:
+  - `npm ci` installs from the lockfile ("added 53 packages");
+  - `npm run check` reports 0 errors and 0 warnings;
+  - `npm run build` builds;
+  - `npm run format:check` reports "All matched files use Prettier code style!".
+- `wails3 build` builds and the app starts under Xvfb.
+- **Exports are byte-identical.** Before the first commit (`main`) and after the last, I ran `go run ./cmd/mpckit-cli -load testdata/device/DemoFile.xpj -out <dir>`. `cmp` finds the two `DemoFile.xpj` identical, and `diff -r` finds the two data folders identical.
+- **No bound method changed:** `TestBoundMethods` passes untouched. `git diff origin/main` shows no change in `frontend/bindings`, in any Go file, or anywhere outside `frontend/`.
+
+**On the laptop (PowerShell, in the repo).** Compare each screen with the current `main` build; nothing should look different.
+1. `git fetch; git checkout frontend-format`
+2. `cd frontend; npm ci; npm run check; npm run format:check; cd ..`: 0 errors and 0 warnings; "All matched files use Prettier code style!".
+3. `wails3 dev`, then load `testdata\device\DemoFile.xpj`. **Pads tab:**
+   - **Grid and help:** the pad grid, the bank bar and the help paragraph under the grid read as one paragraph, with normal spacing.
+   - **Pad panel:** select A1, then A3 and B2. The panel shows the name, file, tune and mute group.
+   - **Edits:** change a tune and a mute group, and the badges on the pads update.
+4. **Move/Copy to pad:**
+   - From the pad panel, open Move to pad and Copy to pad. Check the picker's title, its bank and pad lists, and its hint lines ("… has a sample: the two pads will swap.", "… will be replaced.").
+   - Close the picker with Escape, then with Cancel.
+5. **Notes warning:** drag A1 onto A5. The notes warning dialog shows its message and items. Cancel.
+6. **Sequences tab:** select Intro and Sequence 02. The panel shows the name, BPM and length, and the help line under the grid reads normally.
+   - **Shorten:** set Sequence 02's length to 1 bar. The "Shorten a sequence with notes?" dialog shows its message. Cancel.
+   - **Add:** add a sequence in an empty slot (B5).
+   - **Remove:** remove that new sequence, which shows the "Remove …?" dialog with "The sequence has no recorded notes.". Confirm.
+7. **Export:**
+   - Export to a new folder. The notice shows where it went.
+   - Export there again: "Replace existing files?" lists the items. Cancel.
+   - Export a zip.
+8. **Missing files:** load a copy of DemoFile with one WAV deleted from its data folder.
+   - The missing-file badge and the footer line ("1 sample file missing: export is disabled") show.
+   - Hovering the disabled Export buttons shows the "Export is disabled…" tooltip.
+9. **About, New and close:**
+   - About: the dialog's text, links and Close button.
+   - New with unsaved changes: the "unsaved changes" confirm dialog shows.
+   - Close the window with unsaved changes: the close prompt shows.