5e85d0636133

inline popup smart password generator: parity with main UI compact mode — length slider, A-Z/a-z/0-9/!@# pills, regen+copy buttons, site-rules hint, in both signup and create-entry flows (v2.16.5)

time
2026-05-01 03:31 UTC · 5 months ago
machine
koh2
parent
49c3bcb72e5c
4 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/inject.ts+267 −33
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.16.4",4+  "version": "2.16.5",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.16.4",4+  "version": "2.16.5",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.16.4",3+  "version": "2.16.5",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/content/inject.ts+267 −33
@@ -10,7 +10,7 @@1010  */1111 1212 import { ACCENT_COLORS, type PasswordPolicy, type SavedIdentity } from "../shared/types";13-import { generateSmartPassword } from "../shared/generate";13+import { generatePassword, generateSmartPassword } from "../shared/generate";1414 import { generateTOTP } from "../crypto/totp";1515 import { detectPasswordPolicy } from "./policy";1616 @@ -179,6 +179,181 @@179179   return generateSmartPassword(policy);180180 }181181 182+interface SmartGenerator {183+  root: HTMLElement;184+  getPassword: () => string;185+  /** Copy the current password to the clipboard with the standard pulse UX. */186+  copy: () => Promise<void>;187+}188+189+interface SmartGeneratorOptions {190+  /** Site-detected policy — clamps length, sets symbol toggle, exposes hint. */191+  policy: PasswordPolicy;192+  /**193+   * Seed value: used as the displayed password without regenerating, e.g. the194+   * password the user already typed into the page. The generator still195+   * regenerates the moment any control changes.196+   */197+  initialPassword?: string;198+  /** Fires every time the password regenerates (initial + on changes). */199+  onChange?: (pw: string) => void;200+}201+202+/**203+ * Build the inline-popup smart-password generator. Mirrors the main popup's204+ * GeneratorDrawer (compact mode): readout row + length slider + 4 charset205+ * pills + a policy hint that names the constraints Peach detected on the206+ * page. Writing this in pure DOM rather than reusing the Solid component207+ * keeps the content-script bundle small.208+ */209+function createSmartGenerator(opts: SmartGeneratorOptions): SmartGenerator {210+  const policy = opts.policy ?? {};211+212+  let length = 20;213+  if (policy.minLength) length = Math.max(length, policy.minLength);214+  if (policy.maxLength) length = Math.min(length, policy.maxLength);215+  if (length < 8) length = 8;216+  if (length > 128) length = 128;217+218+  let upper = true;219+  let lower = true;220+  let numbers = true;221+  let symbols = !policy.forbidsSymbols;222+  if (policy.requiresSymbols) symbols = true;223+  const forbiddenChars = policy.forbiddenChars ?? "";224+225+  let password = "";226+227+  const root = document.createElement("div");228+  root.className = "pwgen-block";229+230+  const displayRow = root.appendChild(document.createElement("div"));231+  displayRow.className = "pwgen-display-row";232+  const output = displayRow.appendChild(document.createElement("div"));233+  output.className = "pwgen-output";234+235+  const regenBtn = displayRow.appendChild(document.createElement("button"));236+  regenBtn.className = "pwgen-icon-btn";237+  regenBtn.type = "button";238+  regenBtn.setAttribute("aria-label", "Regenerate password");239+  regenBtn.title = "Regenerate";240+  regenBtn.innerHTML = `<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2.5A5.5 5.5 0 1 0 12.5 7"/><path d="M9.5 2.5H11V1"/></svg>`;241+242+  const copyBtn = displayRow.appendChild(document.createElement("button"));243+  copyBtn.className = "pwgen-icon-btn";244+  copyBtn.type = "button";245+  copyBtn.setAttribute("aria-label", "Copy password");246+  copyBtn.title = "Copy";247+  copyBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="8" height="9" rx="1.5"/><path d="M10 4V2.5A1.5 1.5 0 0 0 8.5 1h-6A1.5 1.5 0 0 0 1 2.5v7A1.5 1.5 0 0 0 2.5 11H4"/></svg>`;248+249+  const controls = root.appendChild(document.createElement("div"));250+  controls.className = "pwgen-controls-row";251+  const slider = controls.appendChild(document.createElement("input"));252+  slider.type = "range";253+  slider.className = "pwgen-slider";254+  slider.min = "8";255+  slider.max = "128";256+  slider.value = String(length);257+  if (policy.minLength) slider.min = String(Math.max(8, policy.minLength));258+  if (policy.maxLength) slider.max = String(Math.min(128, policy.maxLength));259+  const lenNum = controls.appendChild(document.createElement("span"));260+  lenNum.className = "pwgen-len-num";261+  lenNum.textContent = String(length);262+263+  const pills = root.appendChild(document.createElement("div"));264+  pills.className = "pwgen-pills";265+266+  function makePill(label: string, on: boolean, onToggle: (next: boolean) => void): HTMLButtonElement {267+    const b = document.createElement("button");268+    b.type = "button";269+    b.className = `pwgen-pill ${on ? "is-on" : ""}`;270+    b.textContent = label;271+    b.addEventListener("click", () => {272+      const next = !b.classList.contains("is-on");273+      b.classList.toggle("is-on", next);274+      onToggle(next);275+    });276+    return b;277+  }278+  pills.appendChild(makePill("A-Z", upper, (next) => { upper = next; regenerate(); }));279+  pills.appendChild(makePill("a-z", lower, (next) => { lower = next; regenerate(); }));280+  pills.appendChild(makePill("0-9", numbers, (next) => { numbers = next; regenerate(); }));281+  pills.appendChild(makePill("!@#", symbols, (next) => {282+    // The site forbids symbols — UI lets the user override but still warn.283+    symbols = next;284+    regenerate();285+  }));286+287+  // Policy hint — surfaces what Peach read off the site so the user knows the288+  // generator is already tuned to it. Only shows when something interesting289+  // was detected.290+  const hintParts: string[] = [];291+  if (policy.minLength) hintParts.push(`min ${policy.minLength}`);292+  if (policy.maxLength) hintParts.push(`max ${policy.maxLength}`);293+  if (policy.requiresSymbols) hintParts.push("requires symbols");294+  if (policy.forbidsSymbols) hintParts.push("no symbols");295+  if (policy.forbiddenChars) hintParts.push(`excludes ${policy.forbiddenChars.slice(0, 6)}${policy.forbiddenChars.length > 6 ? "…" : ""}`);296+  if (hintParts.length > 0) {297+    const hint = root.appendChild(document.createElement("div"));298+    hint.className = "pwgen-policy-hint";299+    const accent = document.createElement("span");300+    accent.className = "pwgen-hint-accent";301+    accent.textContent = "site rules · ";302+    const text = document.createTextNode(hintParts.join(" · "));303+    hint.appendChild(accent);304+    hint.appendChild(text);305+  }306+307+  function regenerate() {308+    // Falls back gracefully when the user toggles every charset off — the309+    // shared generator forces lowercase in that case rather than throwing.310+    password = generatePassword({311+      length,312+      includeUppercase: upper,313+      includeLowercase: lower,314+      includeNumbers: numbers,315+      includeSymbols: symbols,316+      forbiddenChars,317+    });318+    output.textContent = password;319+    opts.onChange?.(password);320+  }321+322+  slider.addEventListener("input", () => {323+    length = parseInt(slider.value, 10);324+    lenNum.textContent = String(length);325+    regenerate();326+  });327+  regenBtn.addEventListener("click", regenerate);328+329+  let copyResetTimer: ReturnType<typeof setTimeout> | null = null;330+  async function copy(): Promise<void> {331+    try {332+      await navigator.clipboard.writeText(password);333+      copyBtn.classList.add("is-copied");334+      if (copyResetTimer) clearTimeout(copyResetTimer);335+      copyResetTimer = setTimeout(() => copyBtn.classList.remove("is-copied"), 1400);336+    } catch {337+      // Clipboard API may be denied — silently no-op; user still has regen UI.338+    }339+  }340+  copyBtn.addEventListener("click", () => { void copy(); });341+342+  if (opts.initialPassword) {343+    password = opts.initialPassword;344+    output.textContent = password;345+    opts.onChange?.(password);346+  } else {347+    regenerate();348+  }349+350+  return {351+    root,352+    getPassword: () => password,353+    copy,354+  };355+}356+182357 export function observeAnchorPosition(183358   anchorEl: HTMLElement,184359   onUpdate: () => void@@ -819,6 +994,77 @@819994   }820995   .gen-fill-btn:hover { filter: brightness(1.08); transform: translateY(-1px); }821996   .gen-fill-btn:active { transform: translateY(0); }997+998+  /* ── Smart password generator (parity with main UI compact mode) ─── */999+  .pwgen-block { display: flex; flex-direction: column; gap: 8px; }1000+  .pwgen-display-row {1001+    display: flex; align-items: center; gap: 6px;1002+    padding: 8px 10px; border-radius: 7px;1003+    background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);1004+  }1005+  .pwgen-output {1006+    flex: 1; min-width: 0;1007+    font-family: "IBM Plex Mono", monospace; font-size: 12px;1008+    color: var(--peach-text);1009+    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;1010+    user-select: text;1011+  }1012+  .pwgen-icon-btn {1013+    flex-shrink: 0;1014+    width: 26px; height: 26px;1015+    display: inline-flex; align-items: center; justify-content: center;1016+    background: transparent; border: 1px solid var(--peach-input-border);1017+    border-radius: 6px; color: var(--peach-text-dim); cursor: pointer;1018+    padding: 0; transition: background 100ms, color 100ms, border-color 100ms;1019+  }1020+  .pwgen-icon-btn:hover {1021+    background: color-mix(in srgb, var(--peach-accent) 10%, transparent);1022+    color: var(--peach-accent);1023+    border-color: color-mix(in srgb, var(--peach-accent) 30%, transparent);1024+  }1025+  .pwgen-icon-btn.is-copied {1026+    background: color-mix(in srgb, var(--peach-accent) 18%, transparent);1027+    color: var(--peach-accent);1028+    border-color: color-mix(in srgb, var(--peach-accent) 45%, transparent);1029+  }1030+  .pwgen-controls-row {1031+    display: flex; align-items: center; gap: 8px;1032+    flex-wrap: wrap;1033+  }1034+  .pwgen-slider {1035+    flex: 1; min-width: 110px;1036+    accent-color: var(--peach-accent);1037+    cursor: pointer; height: 2px; background: transparent;1038+  }1039+  .pwgen-len-num {1040+    font-family: "IBM Plex Mono", monospace; font-size: 11px;1041+    color: var(--peach-accent); min-width: 22px; text-align: right;1042+  }1043+  .pwgen-pills { display: flex; gap: 5px; flex-wrap: wrap; }1044+  .pwgen-pill {1045+    padding: 3px 8px; border-radius: 999px;1046+    border: 1px solid var(--peach-input-border);1047+    background: transparent; color: var(--peach-text-muted);1048+    font-family: "IBM Plex Mono", monospace; font-size: 9px;1049+    letter-spacing: 0.06em; cursor: pointer;1050+    transition: all 130ms ease;1051+  }1052+  .pwgen-pill.is-on {1053+    border-color: color-mix(in srgb, var(--peach-accent) 50%, transparent);1054+    background: color-mix(in srgb, var(--peach-accent) 10%, transparent);1055+    color: var(--peach-accent);1056+  }1057+  .pwgen-pill:not(.is-on):hover {1058+    color: var(--peach-text-dim);1059+    border-color: var(--peach-text-muted);1060+  }1061+  .pwgen-policy-hint {1062+    font-size: 10px; color: var(--peach-text-muted);1063+    font-family: "IBM Plex Mono", monospace; letter-spacing: 0.04em;1064+    line-height: 1.3;1065+  }1066+  .pwgen-policy-hint .pwgen-hint-accent { color: var(--peach-accent); }1067+8221068   /* ── Save offer (after password filled) ───────────────────── */8231069   .save-offer { padding: 12px; display: flex; flex-direction: column; gap: 8px; }8241070   .save-offer-title {@@ -1013,7 +1259,6 @@10131259   function renderGenerator(filledEmail: string) {10141260     container.innerHTML = "";10151261     const sitePolicy = detectPasswordPolicy();1016-    let pw = generatePasswordForSite(sitePolicy);10171262 10181263     const wrap = container.appendChild(document.createElement("div"));10191264     wrap.className = "gen-view";@@ -1038,34 +1283,25 @@10381283     confVal.className = "gen-confirmed-val";10391284     confVal.textContent = filledEmail;10401285 1041-    // "Generate a password?" label10421286     const lbl = wrap.appendChild(document.createElement("div"));10431287     lbl.className = "gen-label";10441288     lbl.textContent = "Generate a password";10451289 1046-    // Password display + regen1047-    const pwRow = wrap.appendChild(document.createElement("div"));1048-    pwRow.className = "gen-pw-row";1049-    const pwVal = pwRow.appendChild(document.createElement("div"));1050-    pwVal.className = "gen-pw-val";1051-    pwVal.textContent = pw;1052-    const regenBtn = pwRow.appendChild(document.createElement("button"));1053-    regenBtn.className = "gen-regen-btn";1054-    regenBtn.type = "button";1055-    regenBtn.textContent = "⟳ regen";1056-    regenBtn.addEventListener("click", () => {1057-      pw = generatePasswordForSite(sitePolicy);1058-      pwVal.textContent = pw;1290+    let currentPw = "";1291+    const smartGen = createSmartGenerator({1292+      policy: sitePolicy,1293+      onChange: (pw) => { currentPw = pw; },10591294     });1295+    wrap.appendChild(smartGen.root);10601296 1061-    // "Copy & fill" button10621297     const fillBtn = wrap.appendChild(document.createElement("button"));10631298     fillBtn.className = "gen-fill-btn";10641299     fillBtn.type = "button";10651300     fillBtn.textContent = "Copy & fill";10661301     fillBtn.addEventListener("click", () => {1067-      options.onFillFields?.("", pw);1068-      renderSaveOffer(filledEmail, pw);1302+      if (!currentPw) return;1303+      options.onFillFields?.("", currentPw);1304+      renderSaveOffer(filledEmail, currentPw);10691305     });10701306   }10711307 @@ -1321,18 +1557,17 @@13211557     xg.inp.setAttribute("autocomplete", "off");13221558     body.appendChild(xg.g);13231559 1324-    // Password + generate1560+    // Password — full smart generator with charset pills + length slider +1561+    // policy hint. Mirrors the main UI's compact generator so users get the1562+    // same controls inline as in the popup.13251563     const pg = fGroup("password");1326-    const pwInp = document.createElement("input"); pwInp.className = "f-input";1327-    pwInp.type = "text"; pwInp.value = prePassword || generatePasswordForSite(sitePolicy);1328-    pwInp.style.fontFamily = '"IBM Plex Mono", monospace';1329-    pwInp.style.letterSpacing = "0.04em";1330-    pwInp.setAttribute("autocomplete", "off"); pwInp.setAttribute("spellcheck", "false");1331-    const genBtn = document.createElement("button"); genBtn.className = "gen-btn"; genBtn.type = "button";1332-    genBtn.innerHTML = genSvgStr() + " regen";1333-    genBtn.addEventListener("click", () => { pwInp.value = generatePasswordForSite(sitePolicy); });1334-    const pwRow = document.createElement("div"); pwRow.className = "f-row";1335-    pwRow.append(pwInp, genBtn); pg.g.appendChild(pwRow);1564+    let currentPassword = prePassword || "";1565+    const smartGen = createSmartGenerator({1566+      policy: sitePolicy,1567+      initialPassword: prePassword || undefined,1568+      onChange: (pw) => { currentPassword = pw; },1569+    });1570+    pg.g.appendChild(smartGen.root);13361571     body.appendChild(pg.g);13371572 13381573     // Save@@ -1342,7 +1577,7 @@13421577     function showFormError(msg: string) { errEl.textContent = msg; errEl.style.display = ""; }13431578     saveBtn.addEventListener("click", async () => {13441579       const title = tg.inp.value.trim();1345-      const pw = pwInp.value.trim();1580+      const pw = currentPassword.trim();13461581       const username = xg.inp.value.trim();13471582       if (!title) {13481583         tg.inp.style.borderColor = "rgba(220,70,60,0.5)"; tg.inp.focus();@@ -1350,8 +1585,7 @@13501585         return;13511586       }13521587       if (!pw) {1353-        pwInp.style.borderColor = "rgba(220,70,60,0.5)"; pwInp.focus();1354-        showFormError("Entry needs a password");1588+        showFormError("Entry needs a password — tap regen to make one");13551589         return;13561590       }13571591       errEl.style.display = "none";

view file