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
↓ peach-browser-58b08a.tar.gzatlas at this save
files changed7 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 −1manifest.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": {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",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": {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() {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;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()}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 () => {