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