skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
452 lines (399 loc) • 17.9 kB
Markdown
## Contents
- ✅ Friction Audit Checklist
- Form Field Analysis
- Password UX Best Practices (modern, NIST SP 800-63B-aligned)
- Email Verification Flow Optimization
## ✅ Friction Audit Checklist
### Form Field Analysis
**Field Optimization Checklist**
- [ ] **Account-creation fields minimized** (ideally just email + password; defer the rest to progressive profiling)
- [ ] **Name NOT required at account creation** unless the product genuinely needs it now
- [ ] **Optional fields clearly marked** (or removed)
- [ ] **No "confirm password" field** — use a show/hide toggle instead
- [ ] **Password is length-first** (≥12, no composition rules, no `maxlength`<64, paste allowed)
- [ ] **Breached-password screening server-side** (k-anonymity), not arbitrary complexity rules
- [ ] **Passkey/social offered with email+password fallback** ("Sign in with Apple" present on Apple platforms if any social login is offered)
- [ ] **Autocomplete attributes** correct (`email`, `new-password`, `one-time-code`)
- [ ] **Input types/`inputmode` optimized** (`email`, `tel`, `url`, numeric OTP)
- [ ] **Real `<label>` per field** (placeholders are not labels) + `aria-live` errors
- [ ] **Field validation immediate** on blur, constructive messages (not just "invalid")
- [ ] **Marketing consent is a separate, unticked opt-in** (not bundled with Terms)
- [ ] **Tab order logical**; touch targets ≥44–48px; inputs ≥16px font on mobile
- [ ] **Field grouping logical** (related fields together)
```html
<!-- Friction-Optimized Signup Form -->
<form class="low-friction-signup" novalidate>
<!-- Essential fields only -->
<div class="field-group essential">
<label for="email" class="sr-only">Email Address</label>
<input type="email"
id="email"
name="email"
placeholder="your@email.com"
autocomplete="email"
required
aria-describedby="email-help">
<div id="email-help" class="field-help">
We'll send your login link here
</div>
<div class="field-validation" id="email-validation"></div>
</div>
<!-- Password: length-first, paste allowed, show/hide toggle, no
composition rules, no maxlength. -->
<div class="field-group password-group">
<label for="password">Password</label>
<div class="password-field">
<input type="password"
id="password"
name="password"
placeholder="At least 12 characters"
autocomplete="new-password"
minlength="12"
required
aria-describedby="password-help">
<button type="button" class="toggle-password"
aria-label="Show password" aria-pressed="false">Show</button>
</div>
<!-- Strength meter measures real strength (length + uniqueness), not
whether arbitrary character classes are present. -->
<div class="password-strength" aria-live="polite">
<div class="strength-meter">
<div class="strength-fill" data-strength="0"></div>
</div>
<span class="strength-text">Password strength</span>
</div>
<div id="password-help" class="field-help">
Use 12 or more characters. A passphrase of a few random words is great.
You can paste from your password manager.
</div>
</div>
<!-- Progressive enhancement: Only show if user engages -->
<div class="field-group optional hidden" data-progressive="true">
<label for="firstName">First Name (Optional)</label>
<input type="text"
id="firstName"
name="firstName"
placeholder="What should we call you?"
autocomplete="given-name">
</div>
<!-- Simplified terms agreement -->
<div class="terms-group">
<label class="checkbox-label">
<input type="checkbox" required aria-describedby="terms-help">
<span class="checkbox-custom"></span>
<span class="checkbox-text">
I agree to the <a href="/terms" target="_blank">Terms</a> and <a href="/privacy" target="_blank">Privacy Policy</a>
</span>
</label>
<div id="terms-help" class="field-help">
Required to create your account
</div>
</div>
<button type="submit" class="btn-signup" data-loading="false">
<span class="btn-text">Create Account</span>
<span class="btn-loading">Creating Account...</span>
</button>
</form>
```
### Password UX Best Practices (modern, NIST SP 800-63B-aligned)
Modern guidance (NIST SP 800-63B; verify current rev at
https://pages.nist.gov/800-63-4/) inverts the old "make it complex" advice.
Composition rules (force an uppercase + a digit + a symbol) push users toward
predictable patterns like `Password1!` and *reduce* real security while
hurting completion. Do this instead:
- **Length first.** Require **≥ 12** characters (8 is a hard floor); allow
long passphrases (support **≥ 64**). Set `minlength`, never `maxlength`
below 64, and never a `pattern` that mandates character classes.
- **Allow everything.** Accept all Unicode and spaces; **allow paste** so
password managers work. Never block paste.
- **Screen against breached/common passwords** instead of imposing
composition rules — this is the single highest-value check.
- **No forced rotation** and **no security questions.** Only force a reset on
evidence of compromise.
- **Show, don't confirm.** Offer a show/hide toggle; drop the
"confirm password" field.
- **Offer passkeys/social as alternatives** (see sections above) so many
users never create a password at all.
Breach screening must run **server-side**. The client should never download a
breach list or decide acceptance. Use the k-anonymity range API (send only
the first 5 chars of the SHA-1 hash; the service returns suffixes, you match
locally) so the full password/hash never leaves your server:
```javascript
// SERVER-side (Node 18+, global fetch + Web Crypto). Returns how many times
// the password appears in known breaches. 0 = not found. Uses the HIBP
// "range" API via k-anonymity: only a 5-char hash prefix is sent.
import { webcrypto as crypto } from 'node:crypto';
async function breachCount(password) {
const data = new TextEncoder().encode(password);
const digest = await crypto.subtle.digest('SHA-1', data);
const sha1 = [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
// Add-Padding header obscures the count of returned hashes from the network.
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { 'Add-Padding': 'true' },
});
if (!res.ok) return 0; // fail OPEN on availability, but log + monitor it
const body = await res.text();
for (const line of body.split('\n')) {
const [hashSuffix, count] = line.trim().split(':');
if (hashSuffix === suffix) return parseInt(count, 10) || 0;
}
return 0;
}
// Usage in your /api/signup handler:
// if (await breachCount(password) > 0) {
// return reject('This password has appeared in a data breach. Pick another.');
// }
```
The client-side helper below is purely for *encouragement* (a strength meter +
inline hints). It is fully self-contained — every method it calls is defined
here — and it never gates submission on composition. The authoritative checks
(length floor + breach screen) live on the server.
```javascript
// Password UX helper — encouragement only, NOT the source of truth.
class PasswordUX {
constructor(passwordInput) {
this.input = passwordInput;
const group = passwordInput.closest('.field-group') || document;
this.strengthMeter = group.querySelector('.strength-meter .strength-fill');
this.strengthText = group.querySelector('.strength-text');
this.requirementsEl = group.querySelector('#password-help');
this.validationEl = group.querySelector('#password-validation');
this.toggleBtn = group.querySelector('.toggle-password');
this.init();
}
init() {
this.input.addEventListener('input', (e) => {
const pw = e.target.value;
this.updateStrengthIndicator(this.scorePassword(pw));
this.validateInRealTime(pw);
});
this.input.addEventListener('focus', () => this.showPasswordRequirements());
if (this.toggleBtn) {
this.toggleBtn.addEventListener('click', () => this.toggleVisibility());
}
}
// Length-driven score. Each character beyond the 12 floor adds entropy;
// a tiny bonus for variety, but variety is NOT required. No upper cap on
// length. Returns 0-100.
scorePassword(pw) {
if (!pw) return { score: 0, level: 'weak' };
let score = Math.min(70, Math.max(0, (pw.length - 4) * 6)); // length is king
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/]
.filter((re) => re.test(pw)).length;
score += (classes - 1) * 5; // small variety nudge, optional
if (/^(.)\1+$/.test(pw)) score = 10; // all same char
if (this.isObviouslyCommon(pw)) score = 10; // tiny local denylist only
score = Math.max(0, Math.min(100, score));
return { score, level: this.getStrengthLevel(score) };
}
getStrengthLevel(score) {
if (score < 40) return 'weak';
if (score < 60) return 'fair';
if (score < 80) return 'good';
return 'strong';
}
updateStrengthIndicator(strength) {
if (!this.strengthMeter) return;
const colors = { weak: '#ff4757', fair: '#ffa502', good: '#26de81', strong: '#2ed573' };
this.strengthMeter.style.width = `${strength.score}%`;
this.strengthMeter.style.backgroundColor = colors[strength.level];
if (this.strengthText) {
const label = strength.level[0].toUpperCase() + strength.level.slice(1);
this.strengthText.textContent = `${label} password`;
}
}
validateInRealTime(pw) {
if (!this.validationEl) return;
if (pw.length === 0) { this.validationEl.innerHTML = ''; return; }
if (pw.length < 12) {
this.showValidationMessage('A bit longer — aim for 12+ characters', 'warning');
} else {
// Final breach/uniqueness verdict happens on the server at submit;
// here we just reassure once the length floor is met.
this.showValidationMessage('Looks good — we’ll check it against known breaches', 'success');
}
}
showValidationMessage(message, type) {
if (!this.validationEl) return;
this.validationEl.className = `field-validation ${type}`;
this.validationEl.textContent = message;
}
showPasswordRequirements() {
if (this.requirementsEl) {
this.requirementsEl.textContent =
'Use 12+ characters. A few random words make a strong, memorable password. Paste from a manager is fine.';
}
}
// Tiny local denylist for instant feedback ONLY; the real breach screen is
// the server-side k-anonymity check above. Do not rely on this list.
isObviouslyCommon(pw) {
const common = new Set([
'password', 'password1', 'password123', '123456', '12345678',
'qwerty', 'letmein', 'welcome', 'iloveyou', 'admin',
]);
return common.has(pw.toLowerCase());
}
toggleVisibility() {
const showing = this.input.type === 'text';
this.input.type = showing ? 'password' : 'text';
this.toggleBtn.textContent = showing ? 'Show' : 'Hide';
this.toggleBtn.setAttribute('aria-pressed', String(!showing));
this.toggleBtn.setAttribute('aria-label', showing ? 'Show password' : 'Hide password');
}
}
document.addEventListener('DOMContentLoaded', () => {
const passwordInput = document.getElementById('password');
if (passwordInput) new PasswordUX(passwordInput);
});
```
### Email Verification Flow Optimization
```html
<!-- Email Verification Experience -->
<div class="verification-flow" data-step="email-sent">
<div class="verification-content">
<div class="verification-icon">
<svg class="check-email-icon" viewBox="0 0 24 24">
<path d="M20,8L12,13L4,8V6L12,11L20,6M20,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6C22,4.89 21.1,4 20,4Z"/>
</svg>
</div>
<h2>Check Your Email</h2>
<p>We sent a verification link to <strong class="user-email">your@email.com</strong></p>
<div class="verification-actions">
<!-- Link straight to common webmail inboxes by the user's email
domain. This is far more reliable than mobile mail-app deep links
(which frequently dead-end). Native "Open Mail app" is left to the
OS — most users already have the email open in another tab. -->
<a class="btn-primary" id="open-inbox" target="_blank" rel="noopener">
Open your inbox
</a>
<div class="secondary-actions">
<button class="btn-link" id="resend-btn">
Didn't get it? Resend
</button>
<button class="btn-link" onclick="changeEmail()">
Change email address
</button>
</div>
</div>
<!-- Auto-check for verification -->
<div class="auto-verification">
<div class="spinner"></div>
<span>Waiting for you to confirm…</span>
</div>
</div>
</div>
```
**Security & abuse rules baked into the handler below:**
- **Poll a session-scoped status endpoint, not the raw email.** The browser
sends *no* email in the request body; the server reads it from the
authenticated signup session/cookie. Posting the email on a timer leaks PII
and enables enumeration. Send `credentials: 'include'` and a CSRF token.
- **Exponential backoff with jitter**, capped, with a hard stop — never a
fixed 3s hammer.
- **Resend is rate-limited client- *and* server-side** with a visible
cooldown; the server enforces the real limit per account/IP.
- **Identical, non-committal responses** for resend ("If that address needs
confirmation, we’ve sent a link") so the endpoint can't be used to probe
which emails exist.
- **Inbox link is built from the email domain**, not a fragile app deep link.
```javascript
// Email Verification UX Handler — session-scoped, backoff, no PII in requests.
class EmailVerificationFlow {
constructor({ csrfToken } = {}) {
this.csrfToken = csrfToken; // mirror of the CSRF cookie
this.timer = null;
this.delay = 2000; // start at 2s
this.maxDelay = 30000; // back off up to 30s
this.maxDuration = 600000; // give up after 10 min
this.startedAt = Date.now();
this.resendCooldownMs = 30000;
}
start() {
this.schedule();
this.wireUi();
}
schedule() {
if (Date.now() - this.startedAt > this.maxDuration) return this.stop();
const jitter = Math.random() * 500;
this.timer = setTimeout(() => this.checkStatus(), this.delay + jitter);
}
async checkStatus() {
try {
// No body: the server identifies the pending signup from the session.
const res = await fetch('/api/signup/verification-status', {
method: 'GET',
credentials: 'include',
headers: { 'X-CSRF-Token': this.csrfToken || '' },
});
if (res.ok) {
const { verified } = await res.json();
if (verified) return this.onSuccess();
}
} catch (_) {
// Swallow transient errors; we'll retry with a longer delay.
}
this.delay = Math.min(this.delay * 1.7, this.maxDelay); // exponential backoff
this.schedule();
}
stop() { if (this.timer) clearTimeout(this.timer); this.timer = null; }
onSuccess() {
this.stop();
const el = document.querySelector('.verification-content');
if (el) {
el.innerHTML = `
<div class="verification-success">
<div class="success-animation"><svg class="checkmark" viewBox="0 0 50 50">
<circle class="checkmark-circle" cx="25" cy="25" r="25"/>
<path class="checkmark-check" d="m16,25 6,6 12,-12"/>
</svg></div>
<h2>Email confirmed!</h2><p>Taking you in…</p>
</div>`;
}
setTimeout(() => { window.location.href = '/welcome'; }, 1500);
}
wireUi() {
const resendBtn = document.getElementById('resend-btn');
if (resendBtn) resendBtn.addEventListener('click', () => this.resend(resendBtn));
// Build the "Open your inbox" link from the domain shown on the page.
const inbox = document.getElementById('open-inbox');
const emailText = document.querySelector('.user-email')?.textContent || '';
const domain = emailText.split('@')[1]?.toLowerCase();
const webmail = {
'gmail.com': 'https://mail.google.com/',
'googlemail.com': 'https://mail.google.com/',
'outlook.com': 'https://outlook.live.com/mail/',
'hotmail.com': 'https://outlook.live.com/mail/',
'live.com': 'https://outlook.live.com/mail/',
'yahoo.com': 'https://mail.yahoo.com/',
'icloud.com': 'https://www.icloud.com/mail/',
'proton.me': 'https://mail.proton.me/',
};
if (inbox && domain && webmail[domain]) inbox.href = webmail[domain];
else if (inbox) inbox.style.display = 'none'; // unknown host: hide, don't dead-end
}
async resend(btn) {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Sending…';
try {
// Again, no email in the body — server uses the session. Server MUST
// also rate-limit and return the SAME message regardless of state.
await fetch('/api/signup/resend-verification', {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': this.csrfToken || '' },
});
btn.textContent = 'Sent — check your inbox';
} catch (_) {
btn.textContent = 'Try again shortly';
}
// Client-side cooldown (server enforces the authoritative limit).
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, this.resendCooldownMs);
}
}
// const flow = new EmailVerificationFlow({ csrfToken: window.__CSRF__ });
// flow.start();
```