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
↓ peach-browser-5e85d063.tar.gzatlas at this save
files changed4 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/inject.ts+267 −33manifest.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": {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",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": {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";