58b08a

fix: inline edit dismissal and totp accent ring (v1.4.43)

time
2026-04-23 05:13 UTC · 6 months ago
machine
koh2
parent
9caa1b
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+19 −0src/popup/styles/popup.css+32 −0src/popup/views/Entry.tsx+60 −8src/popup/views/contracts.test.tsx+28 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.4.42",4+  "version": "1.4.43",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.4.42",4+  "version": "1.4.43",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "1.4.42",3+  "version": "1.4.43",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/App.tsx+19 −0
@@ -31,6 +31,7 @@3131 3232 const canarySuppressMap = new Map<string, boolean>();3333 let shellRingAnimation: Animation | null = null;34+let totpRingResetTimer: number | null = null;3435 3536 export function isCanarySuppressed(entryId: string): boolean {3637   return canarySuppressMap.get(entryId) ?? false;@@ -74,6 +75,24 @@7475   };7576   animation.onfinish = clearGlow;7677   animation.oncancel = clearGlow;78+}79+80+export function triggerTotpRing() {81+  const shell = document.getElementById("hd-shell");82+  if (!shell) return;83+84+  shell.classList.remove("peach-shell--totp-ring");85+  void shell.offsetWidth;86+  shell.classList.add("peach-shell--totp-ring");87+88+  if (totpRingResetTimer !== null) {89+    window.clearTimeout(totpRingResetTimer);90+  }91+92+  totpRingResetTimer = window.setTimeout(() => {93+    shell.classList.remove("peach-shell--totp-ring");94+    totpRingResetTimer = null;95+  }, 780);7796 }7897 7998 export function triggerPipPulse() {

view file

src/popup/styles/popup.css+32 −0
@@ -96,6 +96,20 @@9696   position: relative;9797   isolation: isolate;9898 }99+.peach-shell::after {100+  content: "";101+  position: absolute;102+  inset: 0;103+  border: 2px solid rgba(217, 104, 48, 0);104+  box-shadow: 0 0 0 rgba(217, 104, 48, 0);105+  border-radius: inherit;106+  opacity: 0;107+  pointer-events: none;108+  z-index: 20;109+}110+.peach-shell.peach-shell--totp-ring::after {111+  animation: peach-edge-ring-breath 780ms cubic-bezier(0.18, 0.8, 0.24, 1) both;112+}99113 .peach-shell[data-surface="extension"] {100114   width: min(320px, 100vw);101115   border-radius: 0;@@ -712,6 +726,24 @@712726 }713727 .peach-shell[data-totp-active="true"] .peach-search-wrap::before {714728   opacity: 0.9;729+}730+731+@keyframes peach-edge-ring-breath {732+  0% {733+    opacity: 0;734+    border-color: rgba(217, 104, 48, 0);735+    box-shadow: 0 0 0 rgba(217, 104, 48, 0);736+  }737+  32% {738+    opacity: 1;739+    border-color: rgba(217, 104, 48, 0.74);740+    box-shadow: 0 0 0 1px rgba(217, 104, 48, 0.28), 0 0 18px rgba(217, 104, 48, 0.18);741+  }742+  100% {743+    opacity: 0;744+    border-color: rgba(217, 104, 48, 0);745+    box-shadow: 0 0 0 rgba(217, 104, 48, 0);746+  }715747 }716748 .peach-search-bar {717749   min-height: 54px;

view file

src/popup/views/Entry.tsx+60 −8
@@ -3,7 +3,7 @@33 import { getDisplayDomain } from "../../shared/domain";44 import { faviconUrl } from "../../shared/favicon";55 import { generateTOTP } from "../../crypto/totp";6-import { sendMessage, triggerPipPulse, triggerRing } from "../App";6+import { sendMessage, triggerPipPulse, triggerRing, triggerTotpRing } from "../App";77 import { GeneratorDrawer } from "../components/GeneratorDrawer";88 99 type EditableField = "title" | "url" | "username" | "password" | "notes" | "totp" | null;@@ -49,7 +49,7 @@4949   createEffect(() => {5050     const state = totpState();5151     if (!state) { prevTotpCode = undefined; return; }52-    if (prevTotpCode !== undefined && state.code !== prevTotpCode) triggerRing();52+    if (prevTotpCode !== undefined && state.code !== prevTotpCode) triggerTotpRing();5353     prevTotpCode = state.code;5454   });5555 @@ -132,6 +132,40 @@132132     setShowGenerator(false);133133   }134134 135+  function fieldValue(entry: VaultEntry, field: Exclude<EditableField, null>): string {136+    switch (field) {137+      case "title":138+        return entry.title;139+      case "url":140+        return entry.url ?? "";141+      case "username":142+        return entry.username ?? "";143+      case "password":144+        return entry.password ?? "";145+      case "notes":146+        return entry.notes ?? "";147+      case "totp":148+        return entry.totp ?? "";149+    }150+  }151+152+  function handleEditorFocusOut(153+    field: Exclude<EditableField, null>,154+    event: FocusEvent & { currentTarget: HTMLElement; relatedTarget: EventTarget | null }155+  ) {156+    const nextTarget = event.relatedTarget;157+    if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;158+159+    const entry = props.entry;160+    if (!entry) return;161+162+    const currentValue = fieldValue(entry, field);163+    const nextValue = field === "password" ? draft() : draft().trim();164+    if (nextValue === currentValue) {165+      cancelEdit();166+    }167+  }168+135169   async function savePassword(password: string) {136170     const entry = props.entry;137171     if (!entry) return;@@ -289,7 +323,10 @@289323                       </button>290324                     }291325                   >292-                    <div class="peach-inline-title-wrap">326+                    <div327+                      class="peach-inline-title-wrap"328+                      onFocusOut={(event) => handleEditorFocusOut("title", event)}329+                    >293330                       <input294331                         class="peach-inline-title-input"295332                         value={draft()}@@ -363,7 +400,10 @@363400                       </div>364401                     }365402                   >366-                    <div class="peach-inline-field-wrap">403+                    <div404+                      class="peach-inline-field-wrap"405+                      onFocusOut={(event) => handleEditorFocusOut("url", event)}406+                    >367407                       <input368408                         class="peach-inline-input"369409                         type="url"@@ -405,7 +445,10 @@405445                       </div>406446                     }407447                   >408-                    <div class="peach-inline-field-wrap">448+                    <div449+                      class="peach-inline-field-wrap"450+                      onFocusOut={(event) => handleEditorFocusOut("notes", event)}451+                    >409452                       <textarea410453                         class="peach-inline-textarea"411454                         value={draft()}@@ -451,7 +494,10 @@451494                         </div>452495                       }453496                     >454-                      <div class="peach-inline-field-wrap">497+                      <div498+                        class="peach-inline-field-wrap"499+                        onFocusOut={(event) => handleEditorFocusOut("username", event)}500+                      >455501                         <input456502                           class="peach-inline-input"457503                           value={draft()}@@ -509,7 +555,10 @@509555                       }510556                     >511557                       {/* ── Edit mode: type / paste / generate ── */}512-                      <div class="peach-inline-field-wrap">558+                      <div559+                        class="peach-inline-field-wrap"560+                        onFocusOut={(event) => handleEditorFocusOut("password", event)}561+                      >513562                         <div class="peach-password-edit-row">514563                           <input515564                             class="peach-inline-input"@@ -589,7 +638,10 @@589638                         </div>590639                       }591640                     >592-                      <div class="peach-inline-field-wrap">641+                      <div642+                        class="peach-inline-field-wrap"643+                        onFocusOut={(event) => handleEditorFocusOut("notes", event)}644+                      >593645                         <textarea594646                           class="peach-inline-textarea"595647                           value={draft()}

view file

src/popup/views/contracts.test.tsx+28 −1
@@ -14,6 +14,7 @@1414 1515 const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();1616 const triggerRingMock = vi.fn();17+const triggerTotpRingMock = vi.fn();1718 const triggerPipPulseMock = vi.fn();1819 const exportEntriesToPeachJsonMock = vi.fn();1920 const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();@@ -22,6 +23,7 @@2223 vi.mock("../App", () => ({2324   sendMessage: (msg: unknown) => sendMessageMock(msg),2425   triggerRing: () => triggerRingMock(),26+  triggerTotpRing: () => triggerTotpRingMock(),2527   triggerPipPulse: () => triggerPipPulseMock(),2628 }));2729 @@ -64,6 +66,7 @@6466     document.body.innerHTML = "";6567     sendMessageMock.mockReset();6668     triggerRingMock.mockReset();69+    triggerTotpRingMock.mockReset();6770     triggerPipPulseMock.mockReset();6871     exportEntriesToPeachJsonMock.mockReset();6972     parseImportSourceMock.mockReset();@@ -90,7 +93,7 @@9093 9194     (globalThis as { chrome?: typeof chrome }).chrome = {9295       runtime: {93-        getManifest: () => ({ version: "1.4.42" }),96+        getManifest: () => ({ version: "1.4.43" }),9497         onMessage: { addListener: vi.fn() },9598       },9699     } as unknown as typeof chrome;@@ -463,6 +466,30 @@463466       type: "COPY",464467       payload: { text: "123456", label: "totp" },465468     });469+  });470+471+  it("Entry closes an unchanged inline editor when focus leaves it", async () => {472+    sendMessageMock.mockResolvedValue({ ok: true });473+    const container = mount(474+      <EntryView475+        entry={sampleEntry()}476+        onDelete={vi.fn()}477+        onFieldSaved={vi.fn(async () => undefined)}478+      />479+    );480+481+    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;482+    usernameValue.click();483+    await flushView();484+485+    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;486+    usernameInput.blur();487+    await flushView();488+489+    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();490+    expect(sendMessageMock).not.toHaveBeenCalledWith(491+      expect.objectContaining({ type: "UPDATE_ENTRY" })492+    );466493   });467494 468495   it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {

view file