reze: Add Node.js password generator with memorable and random modes
Public password generator with no auth and no npm dependencies. - Server-side generation using crypto.randomInt; two levels: diceware-style memorable passphrases (42abacus-91abdomen) and fully random characters. - Configurable word list (EFF Large / EFF Short), digits per word, separators, capitalization, length, character class toggles, custom symbols and ambiguous-character exclusion. - Light/dark theme matching druid520/site, entropy estimate and strength bar, clipboard copy, options persisted in localStorage. - Dockerfile for Dokploy plus a Woodpecker deploy hook on trunk.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Not found · reze-test</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="error">
|
||||
<div class="topbar"><button id="theme-toggle" class="theme-toggle" type="button" hidden>Dark mode</button></div>
|
||||
<h2>Error 404</h2>
|
||||
<h1>Not found</h1>
|
||||
<p>There's nothing at this address.</p>
|
||||
<a href="/">Back to reze-test</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'opts';
|
||||
|
||||
var DEFAULTS = {
|
||||
mode: 'memorable',
|
||||
words: 4,
|
||||
digits: 2,
|
||||
digitSeparator: '',
|
||||
separator: '-',
|
||||
capitalization: 'none',
|
||||
list: 'eff-large',
|
||||
length: 20,
|
||||
useLower: true,
|
||||
useUpper: true,
|
||||
useDigits: true,
|
||||
useSymbols: false,
|
||||
customSymbols: '',
|
||||
excludeAmbiguous: false,
|
||||
requireEach: true,
|
||||
count: 1
|
||||
};
|
||||
|
||||
var FIELDS = [
|
||||
{ key: 'words', id: 'words', type: 'int', min: 2, max: 10 },
|
||||
{ key: 'digits', id: 'digits', type: 'int', min: 0, max: 4 },
|
||||
{ key: 'digitSeparator', id: 'digitSeparator', type: 'str' },
|
||||
{ key: 'separator', id: 'separator', type: 'str' },
|
||||
{ key: 'capitalization', id: 'capitalization', type: 'str' },
|
||||
{ key: 'list', id: 'list', type: 'str' },
|
||||
{ key: 'length', id: 'length', type: 'int', min: 8, max: 64 },
|
||||
{ key: 'count', id: 'count', type: 'int', min: 1, max: 10 },
|
||||
{ key: 'customSymbols', id: 'customSymbols', type: 'str' },
|
||||
{ key: 'useLower', id: 'useLower', type: 'bool' },
|
||||
{ key: 'useUpper', id: 'useUpper', type: 'bool' },
|
||||
{ key: 'useDigits', id: 'useDigits', type: 'bool' },
|
||||
{ key: 'useSymbols', id: 'useSymbols', type: 'bool' },
|
||||
{ key: 'excludeAmbiguous', id: 'excludeAmbiguous', type: 'bool' },
|
||||
{ key: 'requireEach', id: 'requireEach', type: 'bool' }
|
||||
];
|
||||
|
||||
var STRENGTH_LABELS = ['very weak', 'weak', 'fair', 'strong', 'very strong'];
|
||||
var STRENGTH_THRESHOLDS = [28, 40, 60, 80];
|
||||
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function clampInt(value, fallback, min, max) {
|
||||
var n = parseInt(value, 10);
|
||||
if (isNaN(n)) return fallback;
|
||||
if (n < min) return min;
|
||||
if (n > max) return max;
|
||||
return n;
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
var state = {};
|
||||
var key;
|
||||
for (key in DEFAULTS) {
|
||||
if (Object.prototype.hasOwnProperty.call(DEFAULTS, key)) state[key] = DEFAULTS[key];
|
||||
}
|
||||
try {
|
||||
var raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
var saved = JSON.parse(raw);
|
||||
if (saved && typeof saved === 'object') {
|
||||
for (key in saved) {
|
||||
if (Object.prototype.hasOwnProperty.call(state, key)) state[key] = saved[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return state;
|
||||
}
|
||||
|
||||
function saveState(state) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function readState() {
|
||||
var state = {};
|
||||
var i;
|
||||
var field;
|
||||
var el;
|
||||
|
||||
for (i = 0; i < FIELDS.length; i += 1) {
|
||||
field = FIELDS[i];
|
||||
el = byId(field.id);
|
||||
if (!el) continue;
|
||||
if (field.type === 'bool') {
|
||||
state[field.key] = el.checked;
|
||||
} else if (field.type === 'int') {
|
||||
state[field.key] = clampInt(el.value, DEFAULTS[field.key], field.min, field.max);
|
||||
} else {
|
||||
state[field.key] = el.value;
|
||||
}
|
||||
}
|
||||
|
||||
var checked = document.querySelector('input[name="mode"]:checked');
|
||||
state.mode = checked ? checked.value : DEFAULTS.mode;
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function applyState(state) {
|
||||
var i;
|
||||
var field;
|
||||
var el;
|
||||
|
||||
for (i = 0; i < FIELDS.length; i += 1) {
|
||||
field = FIELDS[i];
|
||||
el = byId(field.id);
|
||||
if (!el) continue;
|
||||
if (field.type === 'bool') {
|
||||
el.checked = Boolean(state[field.key]);
|
||||
} else if (field.type === 'int') {
|
||||
el.value = clampInt(state[field.key], DEFAULTS[field.key], field.min, field.max);
|
||||
} else {
|
||||
el.value = state[field.key] === undefined || state[field.key] === null ? '' : state[field.key];
|
||||
}
|
||||
}
|
||||
|
||||
var radios = document.querySelectorAll('input[name="mode"]');
|
||||
for (i = 0; i < radios.length; i += 1) {
|
||||
radios[i].checked = radios[i].value === state.mode;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePanels(state) {
|
||||
var memorable = byId('panel-memorable');
|
||||
var random = byId('panel-random');
|
||||
if (memorable) memorable.hidden = state.mode !== 'memorable';
|
||||
if (random) random.hidden = state.mode !== 'random';
|
||||
}
|
||||
|
||||
function updateLengthOut() {
|
||||
var length = byId('length');
|
||||
var out = byId('length-out');
|
||||
if (length && out) out.textContent = length.value;
|
||||
}
|
||||
|
||||
function strengthLevel(bits) {
|
||||
var i;
|
||||
for (i = 0; i < STRENGTH_THRESHOLDS.length; i += 1) {
|
||||
if (bits < STRENGTH_THRESHOLDS[i]) return i;
|
||||
}
|
||||
return STRENGTH_LABELS.length - 1;
|
||||
}
|
||||
|
||||
function buildStrength(bits) {
|
||||
var level = strengthLevel(bits);
|
||||
var wrap = document.createElement('span');
|
||||
wrap.className = 'strength';
|
||||
wrap.setAttribute('role', 'img');
|
||||
wrap.setAttribute('aria-label', 'strength: ' + STRENGTH_LABELS[level]);
|
||||
var i;
|
||||
for (i = 0; i < STRENGTH_LABELS.length; i += 1) {
|
||||
var seg = document.createElement('i');
|
||||
if (i <= level) seg.className = 'on';
|
||||
wrap.appendChild(seg);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
var box = byId('error');
|
||||
if (!box) return;
|
||||
if (message) {
|
||||
box.textContent = message;
|
||||
box.hidden = false;
|
||||
} else {
|
||||
box.textContent = '';
|
||||
box.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function copyWithFallback(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise(function (resolve, reject) {
|
||||
var area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
area.setAttribute('readonly', 'readonly');
|
||||
area.style.position = 'absolute';
|
||||
area.style.left = '-9999px';
|
||||
document.body.appendChild(area);
|
||||
area.select();
|
||||
var ok = false;
|
||||
try {
|
||||
ok = document.execCommand('copy');
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
}
|
||||
document.body.removeChild(area);
|
||||
if (ok) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('copy failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function copyPassword(value, button) {
|
||||
var original = button.getAttribute('data-label') || 'Copy';
|
||||
copyWithFallback(value).then(function () {
|
||||
button.textContent = 'Copied';
|
||||
}, function () {
|
||||
button.textContent = 'Failed';
|
||||
}).then(function () {
|
||||
window.setTimeout(function () {
|
||||
button.textContent = original;
|
||||
}, 1200);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPasswords(data) {
|
||||
var container = byId('results');
|
||||
if (!container) return;
|
||||
container.textContent = '';
|
||||
|
||||
var list = (data && data.passwords) || [];
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
const item = list[i];
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pw';
|
||||
|
||||
const value = document.createElement('pre');
|
||||
value.className = 'pw-value';
|
||||
value.textContent = item.value;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'pw-meta';
|
||||
|
||||
const entropy = document.createElement('span');
|
||||
entropy.className = 'pw-entropy';
|
||||
entropy.textContent = item.entropy + ' bits';
|
||||
|
||||
const level = strengthLevel(item.entropy);
|
||||
const label = document.createElement('span');
|
||||
label.className = 'pw-strength-label';
|
||||
label.textContent = STRENGTH_LABELS[level];
|
||||
|
||||
const copy = document.createElement('button');
|
||||
copy.type = 'button';
|
||||
copy.className = 'button copy';
|
||||
copy.textContent = 'Copy';
|
||||
copy.setAttribute('data-label', 'Copy');
|
||||
copy.setAttribute('aria-label', 'Copy password');
|
||||
copy.addEventListener('click', function () {
|
||||
copyPassword(item.value, copy);
|
||||
});
|
||||
|
||||
meta.appendChild(entropy);
|
||||
meta.appendChild(buildStrength(item.entropy));
|
||||
meta.appendChild(label);
|
||||
meta.appendChild(copy);
|
||||
|
||||
row.appendChild(value);
|
||||
row.appendChild(meta);
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function payloadFor(state) {
|
||||
if (state.mode === 'random') {
|
||||
return {
|
||||
mode: 'random',
|
||||
length: state.length,
|
||||
useLower: state.useLower,
|
||||
useUpper: state.useUpper,
|
||||
useDigits: state.useDigits,
|
||||
useSymbols: state.useSymbols,
|
||||
customSymbols: state.customSymbols,
|
||||
excludeAmbiguous: state.excludeAmbiguous,
|
||||
requireEach: state.requireEach,
|
||||
count: state.count
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'memorable',
|
||||
words: state.words,
|
||||
digits: state.digits,
|
||||
digitSeparator: state.digitSeparator,
|
||||
separator: state.separator,
|
||||
capitalization: state.capitalization,
|
||||
list: state.list,
|
||||
count: state.count
|
||||
};
|
||||
}
|
||||
|
||||
function generate() {
|
||||
var state = readState();
|
||||
saveState(state);
|
||||
showError('');
|
||||
|
||||
fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payloadFor(state))
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (data) {
|
||||
if (!response.ok) {
|
||||
throw new Error(data && data.error ? data.error : 'request failed with status ' + response.status);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}).then(function (data) {
|
||||
renderPasswords(data);
|
||||
}, function (err) {
|
||||
showError(err && err.message ? err.message : 'request failed');
|
||||
});
|
||||
}
|
||||
|
||||
function loadMeta() {
|
||||
fetch('/api/meta', { headers: { Accept: 'application/json' } }).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (data) {
|
||||
var lists = (data && data.lists) || [];
|
||||
var i;
|
||||
for (i = 0; i < lists.length; i += 1) {
|
||||
var option = document.querySelector('#list option[value="' + lists[i].id + '"]');
|
||||
if (option) option.textContent = lists[i].name + ' (' + lists[i].size + ' words)';
|
||||
}
|
||||
}, function () {});
|
||||
}
|
||||
|
||||
function init() {
|
||||
var state = loadState();
|
||||
applyState(state);
|
||||
updatePanels(state);
|
||||
updateLengthOut();
|
||||
|
||||
var i;
|
||||
var field;
|
||||
for (i = 0; i < FIELDS.length; i += 1) {
|
||||
field = FIELDS[i];
|
||||
(function (f) {
|
||||
var el = byId(f.id);
|
||||
if (!el) return;
|
||||
var handler = function () {
|
||||
var current = readState();
|
||||
saveState(current);
|
||||
updatePanels(current);
|
||||
updateLengthOut();
|
||||
};
|
||||
el.addEventListener('change', handler);
|
||||
el.addEventListener('input', handler);
|
||||
})(field);
|
||||
}
|
||||
|
||||
var radios = document.querySelectorAll('input[name="mode"]');
|
||||
for (i = 0; i < radios.length; i += 1) {
|
||||
radios[i].addEventListener('change', function () {
|
||||
var current = readState();
|
||||
saveState(current);
|
||||
updatePanels(current);
|
||||
});
|
||||
}
|
||||
|
||||
var generateBtn = byId('generate');
|
||||
if (generateBtn) generateBtn.addEventListener('click', generate);
|
||||
var regenerateBtn = byId('regenerate');
|
||||
if (regenerateBtn) regenerateBtn.addEventListener('click', generate);
|
||||
|
||||
loadMeta();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>reze-test</title>
|
||||
<meta name="description" content="Password generator: diceware-style passphrases or fully random characters. Nothing is stored.">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="topbar"><button id="theme-toggle" class="theme-toggle" type="button" hidden>Dark mode</button></div>
|
||||
<h1>reze-test</h1>
|
||||
<p class="lead">Password generator. Diceware-style passphrases or fully random characters — generated locally on the server with a CSPRNG. Nothing you generate is stored.</p>
|
||||
|
||||
<h2>Mode</h2>
|
||||
<div class="radios">
|
||||
<label class="radio"><input type="radio" name="mode" value="memorable" checked> Memorable</label>
|
||||
<label class="radio"><input type="radio" name="mode" value="random"> Random</label>
|
||||
</div>
|
||||
|
||||
<section id="panel-memorable">
|
||||
<h2>Passphrase</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="words">Words per passphrase</label>
|
||||
<input type="number" id="words" min="2" max="10" step="1" value="4">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="digits">Digits per word</label>
|
||||
<input type="number" id="digits" min="0" max="4" step="1" value="2">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="digitSeparator">Digit separator</label>
|
||||
<select id="digitSeparator">
|
||||
<option value="" selected>none</option>
|
||||
<option value="-">- (hyphen)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="separator">Group separator</label>
|
||||
<select id="separator">
|
||||
<option value="-">- (hyphen)</option>
|
||||
<option value="_">_ (underscore)</option>
|
||||
<option value=".">. (dot)</option>
|
||||
<option value=" ">space</option>
|
||||
<option value="">none</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="capitalization">Capitalization</label>
|
||||
<select id="capitalization">
|
||||
<option value="none" selected>none</option>
|
||||
<option value="title">Title case</option>
|
||||
<option value="upper">UPPERCASE</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="list">Word set</label>
|
||||
<select id="list">
|
||||
<option value="eff-large">EFF Large Word List</option>
|
||||
<option value="eff-short">EFF Short Word List (2.0)</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="panel-random" hidden>
|
||||
<h2>Random</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="length">Length</label>
|
||||
<div class="range-row">
|
||||
<input type="range" id="length" min="8" max="64" step="1" value="20">
|
||||
<output id="length-out" for="length">20</output>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checks">
|
||||
<label class="check"><input type="checkbox" id="useLower" checked> lowercase</label>
|
||||
<label class="check"><input type="checkbox" id="useUpper" checked> uppercase</label>
|
||||
<label class="check"><input type="checkbox" id="useDigits" checked> digits</label>
|
||||
<label class="check"><input type="checkbox" id="useSymbols"> symbols</label>
|
||||
<label class="check"><input type="checkbox" id="excludeAmbiguous"> exclude ambiguous characters</label>
|
||||
<label class="check"><input type="checkbox" id="requireEach" checked> require at least one of each selected set</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="customSymbols">Custom symbols</label>
|
||||
<input type="text" id="customSymbols" value="" placeholder="!@#$%^&*()-_=+[]{};:,.<>?" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2>Output</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="count">How many passwords</label>
|
||||
<input type="number" id="count" min="1" max="10" step="1" value="1">
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="generate" class="button primary" type="button">Generate</button>
|
||||
<button id="regenerate" class="button" type="button">Regenerate</button>
|
||||
</div>
|
||||
|
||||
<p id="error" class="error-line" hidden></p>
|
||||
|
||||
<div id="results"></div>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,240 @@
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--fg: #1a1a1a;
|
||||
--muted: #666;
|
||||
--line: #ddd;
|
||||
--code-bg: #f0f0f0;
|
||||
--link: #0b5cad;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #111;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #999;
|
||||
--line: #333;
|
||||
--code-bg: #1c1c1c;
|
||||
--link: #c4a7ff;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #111;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #999;
|
||||
--line: #333;
|
||||
--code-bg: #1c1c1c;
|
||||
--link: #c4a7ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] { color-scheme: light; }
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.25rem;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.75rem; margin: 0 0 2rem; }
|
||||
|
||||
h2 {
|
||||
font-size: .8rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 2.5rem 0 .75rem;
|
||||
}
|
||||
|
||||
a { color: var(--link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.topbar { display: flex; justify-content: flex-end; margin: -1.5rem 0 1.5rem; }
|
||||
.theme-toggle {
|
||||
padding: .25rem .7rem;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: .85rem;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--muted); }
|
||||
.theme-toggle:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: .5rem 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.key { margin: 0 0 1.25rem; }
|
||||
.key p { margin: 0 0 .35rem; color: var(--muted); font-size: .9rem; }
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: .75rem 1rem;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
main.error h2 { margin-top: 0; }
|
||||
main.error h1 { margin-bottom: .75rem; }
|
||||
main.error p { color: var(--muted); margin: 0 0 2rem; }
|
||||
|
||||
.lead { margin: -1.5rem 0 0; color: var(--muted); }
|
||||
|
||||
.radios { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; }
|
||||
|
||||
.radio,
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
.radio input,
|
||||
.check input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
accent-color: var(--link);
|
||||
}
|
||||
|
||||
.checks { display: grid; gap: .4rem; margin-bottom: 1rem; }
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field > label {
|
||||
display: block;
|
||||
margin-bottom: .3rem;
|
||||
color: var(--muted);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: .4rem .55rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
select { cursor: pointer; }
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
accent-color: var(--link);
|
||||
}
|
||||
|
||||
.range-row { display: flex; align-items: center; gap: .75rem; }
|
||||
.range-row output {
|
||||
min-width: 2.5rem;
|
||||
color: var(--muted);
|
||||
font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--link);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: .45rem .9rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: .9rem;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button:hover { border-color: var(--muted); }
|
||||
|
||||
.button.primary {
|
||||
background: var(--link);
|
||||
color: #fff;
|
||||
border-color: var(--link);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .button.primary { color: #111; }
|
||||
}
|
||||
:root[data-theme="dark"] .button.primary { color: #111; }
|
||||
|
||||
.actions { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.25rem 0; }
|
||||
|
||||
.error-line {
|
||||
margin: 0 0 1.25rem;
|
||||
padding-left: .6rem;
|
||||
border-left: 2px solid var(--link);
|
||||
color: var(--fg);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.pw { margin-bottom: 1.5rem; }
|
||||
.pw:last-child { margin-bottom: 0; }
|
||||
|
||||
.pw-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem .9rem;
|
||||
margin-top: .5rem;
|
||||
color: var(--muted);
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.pw-entropy { font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
|
||||
.strength { display: inline-flex; gap: 3px; }
|
||||
.strength i {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--line);
|
||||
}
|
||||
.strength i.on { background: var(--link); }
|
||||
|
||||
.pw-strength-label { text-transform: capitalize; }
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
dl { grid-template-columns: 1fr; gap: 0; }
|
||||
dd { margin-bottom: .75rem; }
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select,
|
||||
input[type="range"] { max-width: 100%; }
|
||||
.actions .button { flex: 1 1 auto; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
var media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
if (saved === 'light' || saved === 'dark') root.dataset.theme = saved;
|
||||
} catch (e) {}
|
||||
|
||||
function current() {
|
||||
return root.dataset.theme || (media.matches ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
function label(btn) {
|
||||
var next = current() === 'dark' ? 'light' : 'dark';
|
||||
btn.textContent = next === 'dark' ? 'Dark mode' : 'Light mode';
|
||||
btn.setAttribute('aria-label', 'Switch to ' + next + ' mode');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
label(btn);
|
||||
btn.hidden = false;
|
||||
btn.addEventListener('click', function () {
|
||||
root.dataset.theme = current() === 'dark' ? 'light' : 'dark';
|
||||
try { localStorage.setItem('theme', root.dataset.theme); } catch (e) {}
|
||||
label(btn);
|
||||
});
|
||||
media.addEventListener('change', function () { label(btn); });
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user