11476aecbc
fitResult() measures the result's natural single-line width and scales the font down (to an 11px floor) so a long password fits on one line on iPhone instead of wrapping; wraps only if even the floor can't fit. Re-fits on resize/rotate. Masking doesn't change width, so reveal is unaffected.
592 lines
28 KiB
HTML
592 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
<meta name="theme-color" content="#0a2540" />
|
|
<title>LesS/KEY password generator</title>
|
|
<meta name="description" content="A password manager that stores nothing and never talks to a server. Passwords are generated entirely in your browser with WebAssembly; your master password and your data never leave your device." />
|
|
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon-32x32.png" />
|
|
<link rel="icon" type="image/png" sizes="16x16" href="./assets/favicon-16x16.png" />
|
|
<link rel="icon" href="./assets/favicon.ico" sizes="any" />
|
|
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png" />
|
|
<link rel="manifest" href="./assets/site.webmanifest" />
|
|
<!-- Installable as a home-screen app (iOS + Android), runs standalone. -->
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
<meta name="apple-mobile-web-app-title" content="LesS/KEY" />
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
<!-- Fonts are self-hosted in style.css (./assets/fonts/); no external requests. -->
|
|
<link rel="stylesheet" href="./style.css" />
|
|
</head>
|
|
<body>
|
|
<header class="site">
|
|
<div class="wrap nav">
|
|
<a class="backlink" href="../" title="Back to the Kaizenkodo site"><span class="arr" aria-hidden="true">←</span> Kaizenkodo</a>
|
|
<a class="brand" href="./">
|
|
<img class="enso" src="./assets/enso.png" width="36" height="36" alt="" aria-hidden="true" />
|
|
<span class="wordmark">LesS/KEY</span>
|
|
<span class="tagline">password generator</span>
|
|
</a>
|
|
<span class="nav-spacer"></span>
|
|
<button class="btn ghost" id="importBtn">Import</button>
|
|
<button class="btn ghost" id="exportBtn">Export</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="wrap">
|
|
<!-- About / how it works (collapsible) -->
|
|
<details class="about" id="about">
|
|
<summary>
|
|
<span class="about-badge">Help</span>
|
|
<span>What is LesS/KEY & how it works</span>
|
|
<span class="about-hint">click to open</span>
|
|
<span class="about-toggle" aria-hidden="true"></span>
|
|
</summary>
|
|
<div class="about-body">
|
|
<p>
|
|
LesS/KEY is a password manager that <strong>stores no passwords</strong>. It
|
|
<strong>re-generates</strong> each one on demand from your single
|
|
<em>master password</em> plus the <em>name</em> of the account. The same name and
|
|
master always produce the same password, so there is nothing secret to leak,
|
|
sync, or back up, and you only ever remember one phrase. By default each password
|
|
is six short, memorable words. That is the <em>correct horse battery staple</em> idea
|
|
from <a href="https://xkcd.com/936/" target="_blank" rel="noopener">xkcd 936</a>, built on
|
|
the classic S/KEY one-time-password scheme (<a href="https://www.rfc-editor.org/rfc/rfc2289" target="_blank" rel="noopener">RFC 2289</a>).
|
|
</p>
|
|
<p class="about-privacy">
|
|
<strong>Nothing leaves your device.</strong> Everything happens locally. Your master
|
|
password and the passwords it generates are never sent anywhere, never stored on a
|
|
server, and never logged. There is no account, no sync, and no tracking. The app is a
|
|
single page plus a WebAssembly module, with its fonts bundled in. Once it has loaded,
|
|
it makes no network requests at all.
|
|
</p>
|
|
<div class="about-grid">
|
|
<div>
|
|
<h4>Quick password</h4>
|
|
<p>
|
|
Type an account name and your master phrase. The password appears instantly,
|
|
masked; click it (or the master) to reveal, or press <strong>Copy</strong>.
|
|
The save button steps from <strong>Store</strong> (file the entry — its name
|
|
and rules, never the password) to <strong>Correct</strong> (confirm this name +
|
|
master); once confirmed the result shows in colour, so a typo stays black.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<h4>Console</h4>
|
|
<p>
|
|
The full command line; type <code>help</code> for every command. It shares the
|
|
same catalog and session as the form above, so you can switch between them freely;
|
|
<code>pb</code> copies a command's output to the clipboard. Your list lives only in
|
|
this browser (localStorage); use <strong>Export</strong> or <strong>Import</strong>
|
|
to move it yourself (for example, to and from a Notion page).
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<h4>Naming rules</h4>
|
|
<p>
|
|
After a name you can add a length and a mode. <code>R</code> is six memorable words
|
|
(the default); <code>C</code> camel; <code>H</code> hex; <code>B</code> base64;
|
|
<code>D</code> digits (<code>U…</code> = upper). For example, <code>github 20R</code>.
|
|
End a name with <code>^folder</code> to derive it from a parent; you still type only
|
|
your one master and the whole chain is computed.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<p class="about-note">
|
|
Runs the real hel engine compiled to WebAssembly, the exact same generator as the
|
|
command-line tool. You can add it to your home screen and use it as an app. No server,
|
|
no account, no tracking, nothing to leak.
|
|
</p>
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Quick generate -->
|
|
<section class="card">
|
|
<div class="eyebrow">Generate</div>
|
|
<h2>Quick password</h2>
|
|
<p class="hint">
|
|
Same name + master always make the same password, computed in your browser —
|
|
your master stays in memory only, never stored or sent. <strong>Store</strong>
|
|
saves the entry (never the password); <strong>Correct</strong> saves a one-way hash.
|
|
</p>
|
|
<div class="fields">
|
|
<div class="field">
|
|
<label for="qname">Name (+ optional rules)</label>
|
|
<input type="text" id="qname" placeholder="github or github 20R 99 2024-01-01" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" autofocus />
|
|
</div>
|
|
<div class="field">
|
|
<label for="master">Master password</label>
|
|
<input type="text" id="master" class="mask" placeholder="your master phrase" title="click to show or hide" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" />
|
|
</div>
|
|
</div>
|
|
<div class="result">
|
|
<span class="tag">result</span>
|
|
<span class="secret" id="qsecret" title="click to reveal"></span>
|
|
<span class="len" id="qlen"></span>
|
|
<span class="actions">
|
|
<button class="btn ghost small" id="saveBtn" title="Save this name in your catalog" disabled>Store</button>
|
|
<button class="btn small" id="copy">Copy</button>
|
|
</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Console -->
|
|
<section class="card">
|
|
<div class="eyebrow">Console</div>
|
|
<h2>All commands</h2>
|
|
<p class="hint">
|
|
The full hel command line: <code>ls</code>, <code>add</code>,
|
|
<code>enc</code>, <code>gen</code>, <code>comment</code>,
|
|
<code>correct</code>, <code>help</code>, … Your catalog persists in this
|
|
browser (localStorage). Generated passwords are masked; click to reveal.
|
|
</p>
|
|
<div class="console-out" id="cout"></div>
|
|
<div class="console-in">
|
|
<span class="prompt">></span>
|
|
<input type="text" id="cin" placeholder="type a command and press Enter" autocomplete="off" spellcheck="false" />
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<footer class="foot wrap">
|
|
A free tool by <a href="https://kaizenkodo.no" target="_blank" rel="noopener">Kaizenkodo</a> (kaizenkodo.no).
|
|
Runs the real hel core compiled to WebAssembly, the same generator as the CLI.
|
|
Everything runs in your browser; nothing you type is ever sent to a server.
|
|
</footer>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<!-- Import modal -->
|
|
<div class="modal-bg" id="importModal">
|
|
<div class="modal">
|
|
<h3>Import catalog</h3>
|
|
<p class="hint">Paste your catalog (e.g. the text of the Notion page). Existing entries with the same name are replaced.</p>
|
|
<textarea id="importText" placeholder="add ..."></textarea>
|
|
<div class="row">
|
|
<button class="btn" id="importDo">Import</button>
|
|
<button class="btn ghost" id="importCancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Export modal -->
|
|
<div class="modal-bg" id="exportModal">
|
|
<div class="modal">
|
|
<h3>Export catalog</h3>
|
|
<p class="hint">Copy this and paste it back into your Notion page.</p>
|
|
<textarea id="exportText" readonly></textarea>
|
|
<div class="row">
|
|
<button class="btn" id="exportCopy">Copy</button>
|
|
<button class="btn ghost" id="exportClose">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password prompt modal (for the `pass` command) -->
|
|
<div class="modal-bg" id="passModal">
|
|
<div class="modal">
|
|
<h3 id="passTitle">Set password</h3>
|
|
<p class="hint">Cache a master/parent password for this name (used by <code>enc</code> in its subtree). Kept in memory only.</p>
|
|
<div class="field">
|
|
<label for="passInput">Password</label>
|
|
<input type="text" id="passInput" class="mask" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" />
|
|
</div>
|
|
<div class="row">
|
|
<button class="btn" id="passOk">Set</button>
|
|
<button class="btn ghost" id="passCancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import init, { hel_init, hel_command, hel_load_script, hel_parse, hel_parse_name, hel_entry } from "./pkg/helwasm.js";
|
|
|
|
const $ = (s) => document.querySelector(s);
|
|
const masterEl = () => document.getElementById("master");
|
|
const CATALOG_KEY = "hel_catalog";
|
|
|
|
// ---- host imports (wasm calls these by bare name → must be globals) ----
|
|
// The master field is the single ROOT master. hel's read_master prompts "/" for
|
|
// the root and the parent's NAME when climbing a ^parent chain; by answering only
|
|
// the "/" prompt (and "" otherwise) we force hel to climb to the root and COMPUTE
|
|
// every intermediate parent from one master — exactly what the CLI does, and the
|
|
// same for the easy form and the console below, so both give identical results.
|
|
window.hel_get_password = (prompt) =>
|
|
prompt === "/" ? (masterEl() ? masterEl().value : "") || "" : "";
|
|
window.hel_rnd_range = (s, e) => {
|
|
if (e <= s) return s;
|
|
const r = crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296;
|
|
return s + Math.floor(r * (e - s));
|
|
};
|
|
window.hel_storage_get = (k) => localStorage.getItem("hel:" + k);
|
|
window.hel_storage_set = (k, v) => localStorage.setItem("hel:" + k, v);
|
|
// `pb` copies to the system clipboard. Fire-and-forget: it runs inside the Enter
|
|
// keypress handler, so the user-gesture requirement for clipboard write is met.
|
|
window.hel_clipboard_write = (text) => { try { navigator.clipboard.writeText(text); } catch (e) {} };
|
|
|
|
// ---- helpers ----
|
|
const persist = () => hel_command("save " + CATALOG_KEY);
|
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
const stripNoise = (out) => out.split("\n").filter((l) => l && !/^(warning|error):/.test(l));
|
|
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
|
let toastT;
|
|
function toast(msg) {
|
|
const t = $("#toast");
|
|
t.textContent = msg;
|
|
t.classList.add("show");
|
|
clearTimeout(toastT);
|
|
toastT = setTimeout(() => t.classList.remove("show"), 1600);
|
|
}
|
|
|
|
async function copyText(text) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
toast("Copied");
|
|
} catch {
|
|
toast("Copy failed");
|
|
}
|
|
}
|
|
|
|
function secretSpan(text) {
|
|
const s = document.createElement("span");
|
|
s.className = "secret";
|
|
s.textContent = text;
|
|
s.title = "click to reveal";
|
|
s.addEventListener("click", () => s.classList.toggle("revealed"));
|
|
return s;
|
|
}
|
|
|
|
// ---- quick generate ----
|
|
// Pure sugar over the same engine the console drives: it only ever runs
|
|
// hel_command(...) and DERIVES what it shows from the engine, so you can switch
|
|
// to the console at any time and keep working on the same catalog, the same
|
|
// cached master, and the same correct hashes. No private form-only state — the
|
|
// master lives only in hel's in-memory `secrets` (never written to storage);
|
|
// only the one-way "correct" hash is persisted on the device.
|
|
let lastSecret = "";
|
|
let appliedMaster = null; // last master value pushed to the engine (debounce hint)
|
|
// One progressive button. It walks the natural states, each a real engine fact:
|
|
// not in catalog -> "Store" (click: add the name)
|
|
// in catalog, master shown -> "Correct" (click: mark this name+master correct)
|
|
// verified -> "Correct ✓" (click: unmark)
|
|
// in catalog, no master -> "Stored ✓" (disabled: nothing to verify yet)
|
|
function refreshSaveBtn(name, hasResult, correct) {
|
|
const b = $("#saveBtn");
|
|
if (!b) return;
|
|
const stored = name ? !!hel_entry(name) : false;
|
|
b.classList.remove("is-correct", "is-stored");
|
|
if (!name) {
|
|
b.disabled = true; b.textContent = "Store";
|
|
b.title = "Save this name in your catalog";
|
|
} else if (!stored) {
|
|
b.disabled = false; b.textContent = "Store";
|
|
b.title = "Save this name in your catalog";
|
|
} else if (correct) {
|
|
b.disabled = false; b.textContent = "Correct ✓"; b.classList.add("is-correct");
|
|
b.title = "This name + master is verified. Click to unmark.";
|
|
} else if (hasResult) {
|
|
b.disabled = false; b.textContent = "Correct";
|
|
b.title = "Mark this name + master correct, so it shows in colour";
|
|
} else {
|
|
b.disabled = true; b.textContent = "Stored ✓"; b.classList.add("is-stored");
|
|
b.title = "In your catalog. Type the master to mark it correct.";
|
|
}
|
|
}
|
|
// Shrink the result font just enough to keep it on one line on very narrow screens
|
|
// (iPhone) instead of wrapping. Falls back to wrapping only if even the floor size
|
|
// can't fit. Masking doesn't change width (mono disc = mono char), so reveal is safe.
|
|
function fitResult() {
|
|
const sec = $("#qsecret");
|
|
sec.style.fontSize = "";
|
|
if (!sec.textContent) return;
|
|
const avail = sec.clientWidth;
|
|
if (!avail) return;
|
|
const ws = sec.style.whiteSpace;
|
|
sec.style.whiteSpace = "nowrap";
|
|
const full = sec.scrollWidth;
|
|
sec.style.whiteSpace = ws;
|
|
if (full > avail) {
|
|
const base = parseFloat(getComputedStyle(sec).fontSize);
|
|
sec.style.fontSize = Math.max(11, Math.floor((base * avail) / full)) + "px";
|
|
}
|
|
}
|
|
function quickGen(silent) {
|
|
const spec = $("#qname").value.trim();
|
|
const sec = $("#qsecret");
|
|
const lenEl = $("#qlen");
|
|
sec.classList.remove("revealed");
|
|
// The entry name is NOT always the first token; a leading prefix
|
|
// (like `*P0 test1 …`) means the name is the next word. Parse it.
|
|
const name = spec ? hel_parse_name(spec) : "";
|
|
if (!name) {
|
|
sec.textContent = ""; sec.classList.remove("correct"); sec.style.fontSize = "";
|
|
lenEl.textContent = ""; lastSecret = "";
|
|
refreshSaveBtn("", false, false);
|
|
return;
|
|
}
|
|
const master = masterEl().value;
|
|
// A new master in the field is authoritative: forget the cached chain so every
|
|
// parent recomputes from it. An empty field keeps whatever master is stored, so
|
|
// names still generate without re-typing it (the auto-`pass`). `unpass` (no arg)
|
|
// is the same command you can type in the console.
|
|
if (master !== appliedMaster) {
|
|
if (master.length) hel_command("unpass");
|
|
appliedMaster = master;
|
|
}
|
|
let out = hel_command("enc " + name);
|
|
if (/^error: name .* not found/m.test(out)) { // unstored leaf: ephemeral add/enc/rm
|
|
hel_command("add " + spec);
|
|
out = hel_command("enc " + name);
|
|
hel_command("rm " + name);
|
|
}
|
|
const pw = stripNoise(out).pop() || "";
|
|
// Colour = "is THIS name + master marked correct?" — read straight from the
|
|
// leaf's own correct-check in the enc output (per name+password, not per master),
|
|
// so changing the name (or mistyping the master) drops back to black until you
|
|
// mark that exact combination. `correct` typed in the console is reflected too.
|
|
const warnRe = new RegExp("^warning: password " + escapeRegExp(name) + " is not marked as correct$", "m");
|
|
const correct = !!pw && !warnRe.test(out);
|
|
lastSecret = pw;
|
|
sec.textContent = pw;
|
|
sec.classList.toggle("correct", correct);
|
|
lenEl.textContent = pw ? "len " + pw.length : "";
|
|
fitResult();
|
|
refreshSaveBtn(name, !!pw, correct);
|
|
if (!pw && !silent) toast("No output");
|
|
}
|
|
// One click advances the progressive button by one step (Store → Correct →
|
|
// unmark). Each step is the same command you could type in the console.
|
|
function saveStep() {
|
|
const b = $("#saveBtn");
|
|
if (!b || b.disabled) return;
|
|
const spec = $("#qname").value.trim();
|
|
const name = hel_parse_name(spec);
|
|
if (!name) return;
|
|
const stored = !!hel_entry(name);
|
|
if (!stored) {
|
|
hel_command("add " + spec); // file the name in the catalog
|
|
persist();
|
|
appendLine('<span class="muted"># stored ' + esc(name) + "</span>");
|
|
toast("Stored " + name);
|
|
} else if (b.classList.contains("is-correct")) {
|
|
hel_command("uncorrect " + name); // stored entry → removes the hash
|
|
toast("No longer marked correct");
|
|
} else {
|
|
hel_command("correct " + name); // stored + master present → verify (colour)
|
|
toast("Marked correct");
|
|
}
|
|
quickGen(true);
|
|
}
|
|
|
|
// ---- console ----
|
|
// Scroll to the bottom after layout (rAF) so the view always follows output.
|
|
function scrollBottom() {
|
|
const out = $("#cout");
|
|
requestAnimationFrame(() => { out.scrollTop = out.scrollHeight; });
|
|
}
|
|
function appendLine(html, cls) {
|
|
const out = $("#cout");
|
|
const div = document.createElement("div");
|
|
if (cls) div.className = cls;
|
|
div.innerHTML = html;
|
|
out.appendChild(div);
|
|
scrollBottom();
|
|
}
|
|
function appendNode(node) {
|
|
const out = $("#cout");
|
|
const div = document.createElement("div");
|
|
div.appendChild(node);
|
|
out.appendChild(div);
|
|
scrollBottom();
|
|
}
|
|
// gen data line: {:>3} {:>36} {:>4} {} → key(0..3) ' ' pass(4..40) ' ' len(41..45) ' ' name(46..)
|
|
// Return the column split so we mask ONLY the password (never the header).
|
|
function parseGenLine(line) {
|
|
if (line.length < 46) return null;
|
|
const lenStr = line.slice(41, 45).trim();
|
|
if (!/^\d+$/.test(lenStr)) return null; // header "Len" → not numeric → plain
|
|
const len = parseInt(lenStr, 10);
|
|
if (len <= 0 || len > 36) return null;
|
|
return { prefix: line.slice(0, 40 - len), secret: line.slice(40 - len, 40), suffix: line.slice(40) };
|
|
}
|
|
function consoleRun(cmd) {
|
|
appendLine('<span class="cmd">> ' + esc(cmd) + "</span>");
|
|
const out = hel_command(cmd);
|
|
persist();
|
|
if (!out) return;
|
|
const verb = cmd.trim().split(/\s+/)[0];
|
|
const isGen = /^gen\d*$/.test(verb); // gen, gen3, gen10, …
|
|
const isEnc = verb === "enc";
|
|
for (const line of out.split("\n")) {
|
|
if (/^(warning|error):/.test(line)) {
|
|
appendLine(esc(line), "err");
|
|
} else if (isGen) {
|
|
const p = parseGenLine(line);
|
|
if (!p) { appendLine(esc(line) || " "); continue; }
|
|
const frag = document.createDocumentFragment();
|
|
frag.appendChild(document.createTextNode(p.prefix));
|
|
frag.appendChild(secretSpan(p.secret));
|
|
frag.appendChild(document.createTextNode(p.suffix));
|
|
appendNode(frag);
|
|
} else if (isEnc && line.trim() && !line.startsWith("add ")) {
|
|
appendNode(secretSpan(line));
|
|
} else {
|
|
appendLine(esc(line) || " ");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- pass overlay (a browser can't block for input, so collect the
|
|
// password in a modal, then run the long form `pass <name> <pw>`) ----
|
|
let passPending = null;
|
|
function openPassOverlay(name) {
|
|
passPending = name;
|
|
$("#passTitle").textContent = 'Password for "' + name + '"';
|
|
$("#passInput").value = "";
|
|
$("#passModal").classList.add("show");
|
|
setTimeout(() => $("#passInput").focus(), 30);
|
|
}
|
|
function submitPass() {
|
|
const name = passPending;
|
|
passPending = null;
|
|
const pw = $("#passInput").value;
|
|
$("#passModal").classList.remove("show");
|
|
$("#passInput").value = "";
|
|
$("#cin").focus(); // return to the command line after the overlay closes
|
|
if (name == null) return;
|
|
appendLine('<span class="cmd">> pass ' + esc(name) + "</span>");
|
|
if (!pw) {
|
|
appendLine('<span class="muted"># cancelled</span>');
|
|
return;
|
|
}
|
|
const out = hel_command("pass " + name + " " + pw); // long form, no prompt
|
|
persist();
|
|
if (out && out.trim()) {
|
|
for (const l of out.split("\n")) appendLine(esc(l), /^(warning|error):/.test(l) ? "err" : null);
|
|
}
|
|
appendLine('<span class="muted"># password cached for ' + esc(name) + "</span>");
|
|
}
|
|
|
|
// ---- boot ----
|
|
async function boot() {
|
|
await init();
|
|
hel_init();
|
|
if (localStorage.getItem("hel:" + CATALOG_KEY)) hel_command("source " + CATALOG_KEY);
|
|
|
|
// remember the About panel's open/closed state across visits
|
|
const about = $("#about");
|
|
const aboutState = localStorage.getItem("hel:_about_open");
|
|
if (aboutState !== null) about.open = aboutState === "1";
|
|
about.addEventListener("toggle", () => localStorage.setItem("hel:_about_open", about.open ? "1" : "0"));
|
|
|
|
// live generation
|
|
let liveT;
|
|
const live = () => {
|
|
clearTimeout(liveT);
|
|
liveT = setTimeout(() => quickGen(true), 70);
|
|
};
|
|
$("#qname").addEventListener("input", live);
|
|
$("#master").addEventListener("input", live);
|
|
window.addEventListener("resize", fitResult); // re-fit on rotate / width change
|
|
$("#qname").addEventListener("keydown", (e) => e.key === "Enter" && quickGen(false));
|
|
// On blur, rewrite the name field to the canonical form hel parsed it as
|
|
// (name + mode + seq + date + comment), so you see exactly what was used.
|
|
$("#qname").addEventListener("blur", () => {
|
|
const spec = $("#qname").value.trim();
|
|
if (!spec) return;
|
|
// If the name is already in the catalog, use its FULL stored spec (real
|
|
// mode/seq/date/parent), not a fresh default-extended parse of what was typed.
|
|
const name = hel_parse_name(spec);
|
|
const stored = name ? hel_entry(name) : "";
|
|
const norm = stored || hel_parse(spec);
|
|
if (norm && norm !== spec) {
|
|
$("#qname").value = norm;
|
|
quickGen(true);
|
|
}
|
|
});
|
|
$("#qsecret").addEventListener("click", () => $("#qsecret").classList.toggle("revealed"));
|
|
// Click the master field to show what you typed; click again to re-mask.
|
|
$("#master").addEventListener("click", () => $("#master").classList.toggle("revealed"));
|
|
$("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy"));
|
|
$("#saveBtn").onclick = saveStep;
|
|
|
|
// pass overlay wiring
|
|
$("#passOk").onclick = submitPass;
|
|
$("#passInput").addEventListener("keydown", (e) => e.key === "Enter" && submitPass());
|
|
$("#passCancel").onclick = () => {
|
|
passPending = null;
|
|
$("#passModal").classList.remove("show");
|
|
$("#passInput").value = "";
|
|
$("#cin").focus();
|
|
};
|
|
|
|
const ci = $("#cin");
|
|
// command history with Up/Down (persisted across reloads)
|
|
const HKEY = "_console_history";
|
|
const history = JSON.parse(localStorage.getItem("hel:" + HKEY) || "[]");
|
|
let hi = history.length; // pointer; === length means "current draft"
|
|
let draft = "";
|
|
const moveCaretEnd = () => requestAnimationFrame(() => ci.setSelectionRange(ci.value.length, ci.value.length));
|
|
ci.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
const v = ci.value.trim();
|
|
ci.value = "";
|
|
if (!v) return;
|
|
if (history[history.length - 1] !== v) history.push(v);
|
|
if (history.length > 300) history.shift();
|
|
localStorage.setItem("hel:" + HKEY, JSON.stringify(history));
|
|
hi = history.length;
|
|
draft = "";
|
|
const pm = v.match(/^pass\s+(\S+)\s*$/); // `pass <name>` → ask in an overlay
|
|
if (pm) { openPassOverlay(pm[1]); return; }
|
|
consoleRun(v);
|
|
} else if (e.key === "ArrowUp") {
|
|
if (hi > 0) {
|
|
if (hi === history.length) draft = ci.value;
|
|
ci.value = history[--hi];
|
|
e.preventDefault();
|
|
moveCaretEnd();
|
|
}
|
|
} else if (e.key === "ArrowDown") {
|
|
if (hi < history.length) {
|
|
hi++;
|
|
ci.value = hi === history.length ? draft : history[hi];
|
|
e.preventDefault();
|
|
moveCaretEnd();
|
|
}
|
|
}
|
|
});
|
|
|
|
$("#importBtn").onclick = () => $("#importModal").classList.add("show");
|
|
$("#importCancel").onclick = () => $("#importModal").classList.remove("show");
|
|
$("#importDo").onclick = () => {
|
|
const txt = $("#importText").value;
|
|
if (txt.trim()) {
|
|
hel_load_script(txt);
|
|
persist();
|
|
toast("Imported");
|
|
appendLine('<span class="muted"># imported catalog</span>');
|
|
}
|
|
$("#importModal").classList.remove("show");
|
|
$("#importText").value = "";
|
|
};
|
|
$("#exportBtn").onclick = () => {
|
|
$("#exportText").value = hel_command("dump"); // `dump` prints the catalog
|
|
$("#exportModal").classList.add("show");
|
|
};
|
|
$("#exportClose").onclick = () => $("#exportModal").classList.remove("show");
|
|
$("#exportCopy").onclick = () => copyText($("#exportText").value);
|
|
// click backdrop closes modals
|
|
document.querySelectorAll(".modal-bg").forEach((bg) =>
|
|
bg.addEventListener("click", (e) => e.target === bg && bg.classList.remove("show"))
|
|
);
|
|
|
|
appendLine('<span class="muted"># LesS/KEY ready. Runs entirely in your browser (WASM); nothing is sent to a server. Try: help</span>');
|
|
}
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|