15583ef6c4c5

phase C: rebind UI in Settings → Shortcuts (per-action chip with capture-mode 'Press a key…' state, conflict detection vs other bindings, per-row reset and reset-all), help dialog overlay (?), App.tsx applyShortcuts persists only deltas vs DEFAULT_BINDINGS so future default updates land for non-customized actions; updates contracts.test.tsx for new SettingsView props (v2.3.0)

time
2026-04-26 21:21 UTC · 6 months ago
machine
koh2
parent
a17baeac9c45
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+65 −0src/popup/styles/popup.css+134 −0src/popup/views/Settings.tsx+131 −11src/popup/views/contracts.test.tsx+5 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.2.3",4+  "version": "2.3.0",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": "2.2.3",4+  "version": "2.3.0",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": "2.2.3",3+  "version": "2.3.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/App.tsx+65 −0
@@ -12,8 +12,10 @@1212 import { popupCopy } from "./copy";1313 import {1414   DEFAULT_BINDINGS,15+  formatKeybinding,1516   getActiveBindings,1617   matchKeybinding,18+  SHORTCUT_DESCRIPTORS,1719   type Keybinding,1820   type ShortcutAction,1921 } from "../shared/shortcuts";@@ -223,6 +225,25 @@223225     await sendMessage({ type: "UPDATE_SETTINGS", payload: { theme: t } });224226   }225227 228+  // Persist only the deltas vs DEFAULT_BINDINGS — keeps the vault payload229+  // small and lets future default updates land for actions the user hasn't230+  // touched. Settings sends us the full active map; we filter and store.231+  async function applyShortcuts(active: Record<ShortcutAction, Keybinding>) {232+    setShortcutBindings(active);233+    const overrides: Record<string, Keybinding> = {};234+    for (const action of Object.keys(active) as ShortcutAction[]) {235+      const a = active[action];236+      const d = DEFAULT_BINDINGS[action];237+      const same = a.key === d.key238+        && Boolean(a.ctrl)  === Boolean(d.ctrl)239+        && Boolean(a.meta)  === Boolean(d.meta)240+        && Boolean(a.shift) === Boolean(d.shift)241+        && Boolean(a.alt)   === Boolean(d.alt);242+      if (!same) overrides[action] = a;243+    }244+    await sendMessage({ type: "UPDATE_SETTINGS", payload: { shortcuts: overrides } });245+  }246+226247   async function applyLockOnClose(enabled: boolean) {227248     setLockOnClose(enabled);228249     await sendMessage({@@ -870,6 +891,8 @@870891                   onTrashRetentionChange={applyTrashRetention}871892                   onThemeChange={applyTheme}872893                   onAccentChange={applyAccentColor}894+                  shortcuts={shortcutBindings()}895+                  onShortcutsChange={applyShortcuts}873896                   onBack={handleBack}874897                   onImport={() => setView("import")}875898                   onExport={() => setView("export")}@@ -943,6 +966,48 @@943966           </Show>944967         </Show>945968       </Show>969+      <Show when={showShortcutsHelp()}>970+        <ShortcutsHelpOverlay971+          bindings={shortcutBindings()}972+          onClose={() => setShowShortcutsHelp(false)}973+        />974+      </Show>975+    </div>976+  );977+}978+979+function ShortcutsHelpOverlay(props: {980+  bindings: Record<ShortcutAction, Keybinding>;981+  onClose: () => void;982+}) {983+  return (984+    <div class="peach-shortcuts-help-backdrop" onClick={props.onClose}>985+      <div class="peach-shortcuts-help-card" onClick={(e) => e.stopPropagation()}>986+        <div class="peach-shortcuts-help-head">987+          <h3>Keyboard shortcuts</h3>988+          <button class="peach-shortcuts-help-close" onClick={props.onClose} aria-label="Close">✕</button>989+        </div>990+        <div class="peach-shortcuts-help-body">991+          <p class="peach-settings-sub-label">ENTRY VIEW</p>992+          <ul>993+            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry").map((d) => (994+              <li>995+                <span>{d.label}</span>996+                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>997+              </li>998+            ))}999+          </ul>1000+          <p class="peach-settings-sub-label">ANYWHERE</p>1001+          <ul>1002+            {SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global").map((d) => (1003+              <li>1004+                <span>{d.label}</span>1005+                <kbd>{formatKeybinding(props.bindings[d.action])}</kbd>1006+              </li>1007+            ))}1008+          </ul>1009+        </div>1010+      </div>9461011     </div>9471012   );9481013 }

view file

src/popup/styles/popup.css+134 −0
@@ -3770,6 +3770,140 @@37703770   color: var(--peach-accent);37713771 }37723772 3773+/* ── Shortcuts rebind table ─────────────────────────────────── */3774+.peach-shortcuts-group { display: flex; flex-direction: column; gap: 6px; }3775+.peach-shortcut-row {3776+  display: flex;3777+  align-items: center;3778+  gap: 10px;3779+  padding: 6px 0;3780+}3781+.peach-shortcut-row-label {3782+  flex: 1;3783+  font-size: 12px;3784+  color: var(--peach-t0);3785+}3786+.peach-shortcut-chip {3787+  min-width: 64px;3788+  padding: 4px 10px;3789+  border-radius: 6px;3790+  border: 1px solid var(--peach-t3);3791+  background: var(--peach-input-bg, var(--surface-card));3792+  color: var(--peach-t0);3793+  font-family: var(--font-mono);3794+  font-size: 11px;3795+  letter-spacing: 0.04em;3796+  cursor: pointer;3797+  text-align: center;3798+  transition: border-color 120ms ease, background 120ms ease, color 120ms ease;3799+}3800+.peach-shortcut-chip:hover { border-color: color-mix(in srgb, var(--peach-accent) 35%, transparent); }3801+.peach-shortcut-chip.is-custom {3802+  border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);3803+  color: var(--peach-accent);3804+}3805+.peach-shortcut-chip.is-capturing {3806+  border-color: var(--peach-accent);3807+  background: color-mix(in srgb, var(--peach-accent) 14%, transparent);3808+  color: var(--peach-accent);3809+  animation: peach-shortcut-capture-pulse 1.4s ease-in-out infinite;3810+}3811+@keyframes peach-shortcut-capture-pulse {3812+  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--peach-accent) 30%, transparent); }3813+  50%      { box-shadow: 0 0 0 4px color-mix(in srgb, var(--peach-accent) 12%, transparent); }3814+}3815+.peach-shortcut-reset {3816+  background: transparent;3817+  border: none;3818+  cursor: pointer;3819+  color: var(--peach-t2);3820+  padding: 4px 6px;3821+  border-radius: 5px;3822+  font-size: 14px;3823+  line-height: 1;3824+  transition: color 120ms ease, background 120ms ease;3825+}3826+.peach-shortcut-reset:hover {3827+  color: var(--peach-t0);3828+  background: color-mix(in srgb, var(--peach-text, var(--peach-t0)) 6%, transparent);3829+}3830+.peach-shortcuts-conflict {3831+  margin: 4px 0 0;3832+  padding: 8px 10px;3833+  border-radius: 7px;3834+  background: color-mix(in srgb, var(--peach-breach) 10%, transparent);3835+  border: 1px solid color-mix(in srgb, var(--peach-breach) 30%, transparent);3836+  font-family: var(--font-mono);3837+  font-size: 11px;3838+  color: var(--peach-t0);3839+}3840+3841+/* ── Help dialog overlay (? to open) ────────────────────────── */3842+.peach-shortcuts-help-backdrop {3843+  position: fixed;3844+  inset: 0;3845+  background: rgba(0, 0, 0, 0.55);3846+  backdrop-filter: blur(2px);3847+  display: flex;3848+  align-items: center;3849+  justify-content: center;3850+  z-index: 1000;3851+  animation: peach-help-fade-in 140ms ease forwards;3852+}3853+@keyframes peach-help-fade-in { from { opacity: 0 } to { opacity: 1 } }3854+.peach-shortcuts-help-card {3855+  width: min(320px, 92vw);3856+  max-height: 80vh;3857+  overflow-y: auto;3858+  border-radius: 14px;3859+  border: 1px solid var(--peach-t3);3860+  background: var(--peach-bg);3861+  box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5);3862+  padding: 16px 18px 18px;3863+  display: flex;3864+  flex-direction: column;3865+  gap: 12px;3866+}3867+.peach-shortcuts-help-head {3868+  display: flex; align-items: center; justify-content: space-between;3869+}3870+.peach-shortcuts-help-head h3 {3871+  margin: 0;3872+  font-family: var(--font-display);3873+  font-style: italic;3874+  font-weight: 400;3875+  font-size: 22px;3876+  color: var(--peach-t0);3877+}3878+.peach-shortcuts-help-close {3879+  background: transparent;3880+  border: none;3881+  color: var(--peach-t2);3882+  cursor: pointer;3883+  font-size: 14px;3884+  padding: 4px 8px;3885+  border-radius: 5px;3886+}3887+.peach-shortcuts-help-close:hover { color: var(--peach-t0); background: var(--surface-card); }3888+.peach-shortcuts-help-body ul {3889+  list-style: none; padding: 0; margin: 4px 0 12px;3890+  display: grid; gap: 8px;3891+}3892+.peach-shortcuts-help-body li {3893+  display: flex; align-items: center; justify-content: space-between; gap: 12px;3894+  font-size: 12px; color: var(--peach-t0);3895+}3896+.peach-shortcuts-help-body kbd {3897+  font-family: var(--font-mono);3898+  font-size: 11px;3899+  padding: 3px 8px;3900+  border-radius: 6px;3901+  background: var(--surface-card);3902+  border: 1px solid var(--peach-t3);3903+  color: var(--peach-accent);3904+  letter-spacing: 0.04em;3905+}3906+37733907 /* ── Pro promo on Account screen ────────────────────────────── */37743908 .peach-pro-promo {37753909   position: relative;

view file

src/popup/views/Settings.tsx+131 −11
@@ -3,6 +3,15 @@33 import { ACCENT_COLORS } from "../../shared/types";44 import type { AccentColorName, PasskeyProviderState } from "../../shared/types";55 import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";6+import {7+  DEFAULT_BINDINGS,8+  findConflict,9+  formatKeybinding,10+  keybindingFromEvent,11+  SHORTCUT_DESCRIPTORS,12+  type Keybinding,13+  type ShortcutAction,14+} from "../../shared/shortcuts";615 716 interface LicenseInfo {817   valid: boolean;@@ -24,6 +33,8 @@2433   onTrashRetentionChange: (days: number) => void | Promise<void>;2534   onThemeChange: (theme: "dark" | "light") => void;2635   onAccentChange: (name: AccentColorName) => void | Promise<void>;36+  shortcuts: Record<ShortcutAction, Keybinding>;37+  onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;2738   onBack: () => void;2839   onImport: () => void;2940   onExport: () => void;@@ -1041,11 +1052,72 @@10411052   }10421053 10431054   function renderShortcuts() {1055+    const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1056+    const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);1057+1058+    function startCapture(action: ShortcutAction) {1059+      setConflictMsg(null);1060+      setCapturingAction(action);1061+    }1062+    function cancelCapture() {1063+      setCapturingAction(null);1064+      setConflictMsg(null);1065+    }1066+    function commitBinding(action: ShortcutAction, binding: Keybinding) {1067+      const conflict = findConflict(props.shortcuts, binding, action);1068+      if (conflict) {1069+        const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1070+        setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1071+        return;1072+      }1073+      const next = { ...props.shortcuts, [action]: binding };1074+      setCapturingAction(null);1075+      setConflictMsg(null);1076+      void props.onShortcutsChange(next);1077+    }1078+    function resetAction(action: ShortcutAction) {1079+      const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1080+      void props.onShortcutsChange(next);1081+    }1082+    function resetAll() {1083+      void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1084+    }1085+1086+    // Capture-mode key handler — wired via ref onto the chip element so it1087+    // catches the next non-modifier keypress and ignores other bindings.1088+    function handleCaptureKey(e: KeyboardEvent) {1089+      e.preventDefault();1090+      e.stopPropagation();1091+      if (e.key === "Escape") { cancelCapture(); return; }1092+      const binding = keybindingFromEvent(e);1093+      if (!binding) return; // bare modifier — keep waiting1094+      const action = capturingAction();1095+      if (action) commitBinding(action, binding);1096+    }1097+10441098     return (10451099       <div class="peach-settings-body">1046-        <div class="peach-settings-group">1100+        <div class="peach-settings-group peach-shortcuts-group">1101+          <p class="peach-settings-sub-label">ENTRY VIEW</p>1102+          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1103+            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1104+          </For>1105+        </div>1106+1107+        <div class="peach-settings-group peach-shortcuts-group">1108+          <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1109+          <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1110+            {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1111+          </For>1112+        </div>1113+1114+        <Show when={conflictMsg()}>1115+          <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1116+        </Show>1117+1118+        <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">10471119           <div class="peach-settings-row peach-settings-row--meta">1048-            <span>Inline popup shortcut</span>1120+            <span>Inline autofill on page</span>10491121             <a10501122               class="peach-settings-link"10511123               href="chrome://extensions/shortcuts"@@ -1054,20 +1126,68 @@10541126                 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });10551127               }}10561128             >1057-              Ctrl+Shift+Space — customize ↗1129+              ⌘⇧P — customize ↗10581130             </a>10591131           </div>1060-          <div class="peach-shortcuts-grid">1061-            <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>1062-            <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>1063-            <span class="peach-shortcut-key">/</span><span class="peach-shortcut-desc">search vault</span>1064-            <span class="peach-shortcut-key">↑ ↓</span><span class="peach-shortcut-desc">navigate entries</span>1065-            <span class="peach-shortcut-key">Esc</span><span class="peach-shortcut-desc">back / close</span>1066-            <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>1067-          </div>1132+          <button1133+            class="peach-btn peach-btn--subtle peach-btn--sm"1134+            style="align-self:flex-start"1135+            onClick={resetAll}1136+          >1137+            Reset all to defaults1138+          </button>10681139         </div>10691140       </div>10701141     );1142+1143+    function renderRow(1144+      desc: typeof SHORTCUT_DESCRIPTORS[number],1145+      capturing: () => ShortcutAction | null,1146+      onStart: (a: ShortcutAction) => void,1147+      onCancel: () => void,1148+      onReset: (a: ShortcutAction) => void,1149+      onKey: (e: KeyboardEvent) => void,1150+    ) {1151+      const isActive = () => capturing() === desc.action;1152+      const binding = () => props.shortcuts[desc.action];1153+      const isCustom = () => {1154+        const b = binding();1155+        const d = DEFAULT_BINDINGS[desc.action];1156+        return b.key !== d.key1157+          || Boolean(b.ctrl)  !== Boolean(d.ctrl)1158+          || Boolean(b.meta)  !== Boolean(d.meta)1159+          || Boolean(b.shift) !== Boolean(d.shift)1160+          || Boolean(b.alt)   !== Boolean(d.alt);1161+      };1162+      return (1163+        <div class="peach-shortcut-row">1164+          <span class="peach-shortcut-row-label">{desc.label}</span>1165+          <button1166+            class="peach-shortcut-chip"1167+            classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1168+            onClick={() => isActive() ? onCancel() : onStart(desc.action)}1169+            onKeyDown={(e) => { if (isActive()) onKey(e); }}1170+            ref={(el) => {1171+              // Auto-focus when entering capture mode so the keydown listener catches the next press.1172+              if (el) {1173+                queueMicrotask(() => { if (isActive()) el.focus(); });1174+              }1175+            }}1176+          >1177+            {isActive() ? "Press a key…" : formatKeybinding(binding())}1178+          </button>1179+          <Show when={isCustom() && !isActive()}>1180+            <button1181+              class="peach-shortcut-reset"1182+              title="Reset to default"1183+              onClick={() => onReset(desc.action)}1184+            >1185+              ↺1186+            </button>1187+          </Show>1188+        </div>1189+      );1190+    }10711191   }10721192 10731193   function renderLicense() {

view file

src/popup/views/contracts.test.tsx+5 −0
@@ -8,6 +8,7 @@88 import { LockView } from "./Lock";99 import { ExportView } from "./Export";1010 import { SettingsView } from "./Settings";11+import { DEFAULT_BINDINGS } from "../../shared/shortcuts";1112 import { EntryView } from "./Entry";1213 import { ImportView } from "./Import";1314 import { VaultView } from "./Vault";@@ -319,6 +320,8 @@319320         onTrashRetentionChange={vi.fn()}320321         onThemeChange={vi.fn()}321322         onAccentChange={vi.fn()}323+        shortcuts={DEFAULT_BINDINGS}324+        onShortcutsChange={vi.fn()}322325         onBack={vi.fn()}323326         onImport={vi.fn()}324327         onExport={vi.fn()}@@ -374,6 +377,8 @@374377         onTrashRetentionChange={vi.fn()}375378         onThemeChange={onThemeChange}376379         onAccentChange={vi.fn()}380+        shortcuts={DEFAULT_BINDINGS}381+        onShortcutsChange={vi.fn()}377382         onBack={vi.fn()}378383         onImport={vi.fn()}379384         onExport={vi.fn()}

view file