feat(web): polish the WASM site - UX, help, parsing, assets

- About panel (collapsible, closed by default with a pulsing "Help" badge)
  explaining the tool; the `help` command now prints a full command reference
  instead of "HELP" (benefits the CLI too).
- Live generation as you type; Store button; masked output that stays mono-width
  (gen masks only the password column, never the header); command history with
  Up/Down (persisted); console auto-scroll; Export fixed (uses `dump`); Import.
- Name field normalizes to its canonical parsed form on blur; quick-gen and Store
  resolve the real entry name, so a leading prefix like `*P0 test1` is handled
  correctly (new hel_parse / hel_parse_name wasm helpers).
- Parser: prefix-only forms now parse with defaults - `*P0 test1` ->
  `*P0 test1 R 99 <today>`, `*P0 test1 R`, `*P0 test1 20R` - via new qpname/npname
  rules mirroring qname/nname; the fully-qualified form is unchanged.
- Favicons + web app manifest + enso asset; pkg rebuilt.
This commit is contained in:
Oleksandr Kozachuk
2026-06-08 20:11:24 +02:00
parent 03c5dadccb
commit 3031105000
17 changed files with 503 additions and 98 deletions
+7 -1
View File
@@ -37,13 +37,19 @@ peg::parser! {
{ Password::new(None, pn, pl, pm, ps, pd, pc) }
rule mname() -> Password = &(word() _ word() _ num()? mode() _ date()) pr:word() _ pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()?
{ Password::new(Some(pr), pn, pl, pm, 99, pd, pc) }
// prefix + name + [len]mode (no seq/date) -> defaults seq 99, date now
rule npname() -> Password = &(word() _ word() _ num()? mode()) pr:word() _ pn:word() _ pl:num()? pm:mode()
{ Password::new(Some(pr), pn, pl, pm, 99, Date::now(), None) }
rule sname() -> Password = &(word() _ num()? mode() _ date()) pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()?
{ Password::new(None, pn, pl, pm, 99, pd, pc) }
rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode()
{ Password::new(None, pn, pl, pm, 99, Date::now(), None) }
// prefix + name only -> defaults mode R, seq 99, date now
rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:word()
{ Password::new(Some(pr), pn, None, Mode::Regular, 99, Date::now(), None) }
rule qname() -> Password = &(word()) pn:word()
{ Password::new(None, pn, None, Mode::NoSpaceCamel, 99, Date::now(), None) }
pub rule name() -> Password = name:(jname() / pname() / mname() / sname() / nname() / qname())? {?
pub rule name() -> Password = name:(jname() / pname() / mname() / npname() / sname() / nname() / qpname() / qname())? {?
match name { Some(n) => Ok(n), None => Err("failed to parse password description") }
}
+23 -1
View File
@@ -141,7 +141,29 @@ impl<'a> LKEval<'a> {
Command::Uncorrect(name) => self.cmd_correct(&out, name, false, None),
Command::Noop => { to_history = false; },
Command::Help => {
out.o("HELP".to_string());
out.o(concat!(
"hel - passwords are generated from your master + the entry name; nothing secret is stored.\n",
"\n",
"entries\n",
" add <name> [len][mode] [seq] [date] [text] [^parent] define an entry\n",
" ls [regex] list by name ld [regex] list by date\n",
" mv <name> <new> rename / move rm <name> remove\n",
" comment <name> [text] set or clear the comment\n",
"\n",
"passwords\n",
" enc <name> show the generated password\n",
" gen[N] <name> N numbered variants; name ends in G.. (all) or X.. (random)\n",
" pass <name> [pw] cache a master / override for an entry's subtree\n",
" unpass <name> forget a cached password (unpass / = the root master)\n",
" correct <name> trust this password's hash uncorrect <name> untrust it\n",
"\n",
"catalog\n",
" dump print the catalog ls list it\n",
" save [target] write it (file / localStorage key / |command)\n",
" source <target> load it set <key> <val> config\n",
"\n",
"modes R regular C camel N nospace H hex B base64 D decimal (U.. = UPPER)"
).to_string());
}
Command::Mv(name, folder) => self.cmd_mv(&out, &name, &folder),
Command::Error(error) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"name": "LesS/KEY — password generator",
"short_name": "LesS/KEY",
"icons": [
{ "src": "./icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "./icon-512.png", "sizes": "512x512", "type": "image/png" }
],
"theme_color": "#0a2540",
"background_color": "#f4f0e8",
"display": "standalone"
}
+265 -44
View File
@@ -3,8 +3,13 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#1b4161" />
<meta name="theme-color" content="#0a2540" />
<title>LesS/KEY — password generator</title>
<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" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
@@ -17,8 +22,9 @@
<header class="site">
<div class="wrap nav">
<a class="brand" href="./">
<span class="enso" aria-hidden="true"></span>
<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>
@@ -27,32 +33,82 @@
</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 &amp; 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 +
master always produce the same password — so there is nothing secret to leak,
sync, or back up, and you only ever remember one phrase.
</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 to reveal, or press <strong>Copy</strong>. <strong>Store</strong>
remembers the <em>name</em> (never the password) in this browser.
</p>
</div>
<div>
<h4>Console</h4>
<p>
The full command line — type <code>help</code> for every command. Your list of
names lives only in this browser (localStorage); use <strong>Export</strong> /
<strong>Import</strong> to move it (e.g. 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> words,
<code>C</code> camel, <code>H</code> hex, <code>B</code> base64, <code>D</code>
digits (<code>U…</code> = upper). E.g. <code>github 20R</code>.
</p>
</div>
</div>
<p class="about-note">
Runs the real hel engine compiled to WebAssembly — the exact same generator as the
command-line tool. Nothing is ever sent to a server.
</p>
</div>
</details>
<!-- Quick generate -->
<section class="card">
<div class="eyebrow">Generate</div>
<h2>Quick password</h2>
<p class="hint">
Deterministic — the same name + master always regenerate the same
password. Nothing secret is stored; this entry is not added to your
catalog.
password. The result updates live as you type. Nothing is stored unless
you press <strong>Store</strong>.
</p>
<div class="grid2">
<div class="fields">
<div class="field">
<label for="qname">Name (+ optional rules)</label>
<input type="text" id="qname" placeholder="exa91 or exa91 20R 99 2020-01-01" autocomplete="off" spellcheck="false" autofocus />
<input type="text" id="qname" placeholder="github or github 20R 99 2024-01-01" autocomplete="off" spellcheck="false" autofocus />
</div>
<div class="field">
<label for="master">Master password</label>
<input type="password" id="master" placeholder="your master phrase" autocomplete="off" />
</div>
</div>
<div class="row">
<button class="btn" id="show">Show</button>
<button class="btn ghost" id="copy">Copy</button>
</div>
<div class="secret-line">
<div class="result">
<span class="tag">result</span>
<span id="qsecret"><span class="secret"></span></span>
<span class="secret" id="qsecret" title="click to reveal"></span>
<span class="len" id="qlen"></span>
<span class="actions">
<button class="btn small" id="copy">Copy</button>
<button class="btn ghost small" id="store">Store</button>
</span>
</div>
</section>
@@ -64,8 +120,7 @@
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). <code>enc</code>/<code>gen</code> output is
masked — click to reveal.
browser (localStorage). Generated passwords are masked — click to reveal.
</p>
<div class="console-out" id="cout"></div>
<div class="console-in">
@@ -107,8 +162,24 @@
</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="password" id="passInput" autocomplete="off" />
</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 } from "./pkg/helwasm.js";
import init, { hel_init, hel_command, hel_load_script, hel_parse, hel_parse_name } from "./pkg/helwasm.js";
const $ = (s) => document.querySelector(s);
const masterEl = () => document.getElementById("master");
@@ -127,10 +198,7 @@
// ---- helpers ----
const persist = () => hel_command("save " + CATALOG_KEY);
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const isSecretCmd = (cmd) => {
const v = cmd.trim().split(/\s+/)[0];
return v === "enc" || v === "gen";
};
const stripNoise = (out) => out.split("\n").filter((l) => l && !/^(warning|error):/.test(l));
let toastT;
function toast(msg) {
@@ -159,73 +227,222 @@
return s;
}
// ---- quick generate (stateless: never pollutes the stored catalog) ----
// ---- quick generate (live; stateless unless Store) ----
let lastSecret = "";
function quickShow() {
function quickGen(silent) {
const spec = $("#qname").value.trim();
if (!spec) return toast("Enter a name");
if (!masterEl().value) return toast("Enter the master password");
const name = spec.split(/\s+/)[0];
const master = masterEl().value;
const sec = $("#qsecret");
const lenEl = $("#qlen");
sec.classList.remove("revealed");
if (!spec || !master) {
sec.textContent = "";
lenEl.textContent = "";
lastSecret = "";
return;
}
// The entry name is NOT always the first token — a leading prefix
// (e.g. `*P0 test1 …`) means the name is the next word. Parse it.
const name = hel_parse_name(spec);
if (!name) {
sec.textContent = "";
lenEl.textContent = "";
lastSecret = "";
return;
}
// hel caches the root master in secrets["/"]; clear it so the live result
// always reflects the current master field (not a stale cached value).
hel_command("unpass /");
let out = hel_command("enc " + name);
if (/not found/.test(out)) {
hel_command("add " + spec);
out = hel_command("enc " + name);
hel_command("rm " + name);
}
const pw = out.split("\n").filter((l) => l && !/^(warning|error):/.test(l)).pop() || "";
hel_command("unpass /");
const pw = stripNoise(out).pop() || "";
lastSecret = pw;
const slot = $("#qsecret");
slot.innerHTML = "";
slot.appendChild(secretSpan(pw));
if (!pw) toast("No output");
sec.textContent = pw;
lenEl.textContent = pw ? "len " + pw.length : "";
if (!pw && !silent) toast("No output");
}
function quickStore() {
const spec = $("#qname").value.trim();
if (!spec) return toast("Enter a name");
const name = hel_parse_name(spec) || spec.split(/\s+/)[0];
const out = hel_command("add " + spec);
persist();
appendLine('<span class="muted"># stored ' + esc(name) + "</span>");
toast(/^(error|warning)/m.test(out) ? out.split("\n")[0] : "Stored " + name);
}
// ---- 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);
out.scrollTop = out.scrollHeight;
scrollBottom();
}
function appendSecret(text) {
function appendNode(node) {
const out = $("#cout");
const div = document.createElement("div");
div.appendChild(secretSpan(text));
div.appendChild(node);
out.appendChild(div);
out.scrollTop = out.scrollHeight;
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">&gt; ' + esc(cmd) + "</span>");
const out = hel_command(cmd);
persist();
if (out) {
const secret = isSecretCmd(cmd);
for (const line of out.split("\n")) {
if (/^(warning|error):/.test(line)) appendLine(esc(line), "err");
else if (secret && line.trim() && !line.startsWith("add ")) appendSecret(line);
else appendLine(esc(line) || "&nbsp;");
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) || "&nbsp;"); 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) || "&nbsp;");
}
}
}
// ---- 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 = "";
if (name == null) return;
appendLine('<span class="cmd">&gt; 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);
$("#show").onclick = quickShow;
// 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);
$("#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;
const norm = hel_parse(spec);
if (norm && norm !== spec) {
$("#qname").value = norm;
quickGen(true);
}
});
$("#qsecret").addEventListener("click", () => $("#qsecret").classList.toggle("revealed"));
$("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy"));
$("#qname").addEventListener("keydown", (e) => e.key === "Enter" && quickShow());
$("#store").onclick = quickStore;
// pass overlay wiring
$("#passOk").onclick = submitPass;
$("#passInput").addEventListener("keydown", (e) => e.key === "Enter" && submitPass());
$("#passCancel").onclick = () => {
passPending = null;
$("#passModal").classList.remove("show");
$("#passInput").value = "";
};
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;
if (v.trim()) consoleRun(v);
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();
}
}
});
@@ -243,11 +460,15 @@
$("#importText").value = "";
};
$("#exportBtn").onclick = () => {
$("#exportText").value = hel_command("dump -");
$("#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 the real hel core via WASM. Try: help</span>');
}
+18 -1
View File
@@ -19,13 +19,30 @@ export function hel_init(): void;
*/
export function hel_load_script(script: string): string;
/**
* Parse a password spec (e.g. `exa91` or `exa91 20R 99 2020-01-01`) and return
* the canonical normalized form hel actually uses (name + mode + seq + date +
* comment), without touching state. Returns the input unchanged if it does not
* parse. Used by the UI to rewrite the name field on blur.
*/
export function hel_parse(spec: string): string;
/**
* Parse a spec and return just the entry name hel resolves it to. Handles a
* leading prefix (e.g. `*P0 test1 …` → `test1`), which a naive first-token
* split would get wrong. Falls back to the first whitespace token.
*/
export function hel_parse_name(spec: string): string;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly hel_command: (a: number, b: number) => [number, number];
readonly hel_init: () => void;
readonly hel_command: (a: number, b: number) => [number, number];
readonly hel_load_script: (a: number, b: number) => [number, number];
readonly hel_parse: (a: number, b: number) => [number, number];
readonly hel_parse_name: (a: number, b: number) => [number, number];
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
+45
View File
@@ -49,6 +49,51 @@ export function hel_load_script(script) {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* Parse a password spec (e.g. `exa91` or `exa91 20R 99 2020-01-01`) and return
* the canonical normalized form hel actually uses (name + mode + seq + date +
* comment), without touching state. Returns the input unchanged if it does not
* parse. Used by the UI to rewrite the name field on blur.
* @param {string} spec
* @returns {string}
*/
export function hel_parse(spec) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(spec, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.hel_parse(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* Parse a spec and return just the entry name hel resolves it to. Handles a
* leading prefix (e.g. `*P0 test1 …` → `test1`), which a naive first-token
* split would get wrong. Falls back to the first whitespace token.
* @param {string} spec
* @returns {string}
*/
export function hel_parse_name(spec) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(spec, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.hel_parse_name(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
Binary file not shown.
+3 -1
View File
@@ -1,9 +1,11 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const hel_command: (a: number, b: number) => [number, number];
export const hel_init: () => void;
export const hel_command: (a: number, b: number) => [number, number];
export const hel_load_script: (a: number, b: number) => [number, number];
export const hel_parse: (a: number, b: number) => [number, number];
export const hel_parse_name: (a: number, b: number) => [number, number];
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
+23
View File
@@ -23,6 +23,29 @@ pub fn hel_command(cmd: String) -> String {
lkprint.out.output().join("\n")
}
/// Parse a password spec (e.g. `exa91` or `exa91 20R 99 2020-01-01`) and return
/// the canonical normalized form hel actually uses (name + mode + seq + date +
/// comment), without touching state. Returns the input unchanged if it does not
/// parse. Used by the UI to rewrite the name field on blur.
#[wasm_bindgen]
pub fn hel_parse(spec: String) -> String {
match command_parser::name(&spec) {
Ok(p) => p.to_string().trim().to_string(),
Err(_) => spec,
}
}
/// Parse a spec and return just the entry name hel resolves it to. Handles a
/// leading prefix (e.g. `*P0 test1 …` → `test1`), which a naive first-token
/// split would get wrong. Falls back to the first whitespace token.
#[wasm_bindgen]
pub fn hel_parse_name(spec: String) -> String {
match command_parser::name(&spec) {
Ok(p) => p.name,
Err(_) => spec.split_whitespace().next().unwrap_or("").to_string(),
}
}
/// Run a whole multi-line script (every `add …` line, `set …`, etc.) against the
/// shared state in one call. Used to bulk-import a pasted catalog (e.g. the text
/// of the Notion page) and to load the persisted catalog from localStorage.
+108 -50
View File
@@ -15,8 +15,9 @@
--disp: "Poppins", system-ui, sans-serif;
--body: "Inter", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
--maxw: 880px;
--radius: 14px;
--maxw: 1320px;
--gutter: clamp(16px, 3.5vw, 48px);
--radius: 16px;
}
/* Masked-secret font: dots glyphs so the real chars stay selectable/copyable.
@@ -29,7 +30,6 @@
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { height: 100%; }
body {
@@ -44,9 +44,10 @@ body {
linear-gradient(90deg, rgba(27, 65, 97, 0.05) 1px, transparent 1px);
background-size: 48px 48px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 20px; }
.wrap { width: 100%; max-width: var(--maxw); margin: 0 auto; padding-inline: var(--gutter); }
/* Header */
.site {
@@ -55,92 +56,148 @@ body {
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.nav { display: flex; align-items: center; gap: 14px; height: 64px; }
.nav { display: flex; align-items: center; gap: 14px; height: 68px; }
.brand { display: inline-flex; align-items: center; gap: 11px; text-decoration: none; }
.enso {
width: 34px; height: 34px; border-radius: 50%;
border: 3px solid var(--ocean); border-right-color: transparent;
transform: rotate(-20deg);
}
.wordmark { font-family: var(--disp); font-weight: 700; font-size: 21px; color: var(--ocean); letter-spacing: -0.01em; }
.enso { width: 36px; height: 36px; display: block; flex: none; object-fit: contain; }
.wordmark { font-family: var(--disp); font-weight: 700; font-size: 22px; color: var(--ocean); letter-spacing: -0.01em; }
.tagline { font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 0.08em; }
.nav-spacer { flex: 1; }
.nav .ghost { font-family: var(--mono); font-size: 12px; }
/* Eyebrow label */
.eyebrow {
font-family: var(--mono); font-size: 12px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--ocean);
display: inline-flex; align-items: center; gap: 9px; margin-bottom: 14px;
display: inline-flex; align-items: center; gap: 9px; margin-bottom: 12px;
}
.eyebrow::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: var(--seal); box-shadow: 0 0 0 3px rgba(226, 59, 46, 0.16);
}
main { padding: 30px 0 64px; display: grid; gap: 22px; }
main { padding: clamp(20px, 3vw, 34px) 0 56px; display: grid; gap: clamp(16px, 2.4vw, 24px); }
/* About / how-it-works (collapsible <details>) */
.about { background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.about > summary {
list-style: none; cursor: pointer; display: flex; align-items: center; gap: 12px;
padding: clamp(14px, 2.2vw, 19px) clamp(16px, 2.6vw, 28px);
font-family: var(--disp); font-weight: 600; font-size: clamp(16px, 2vw, 20px); color: var(--ocean);
}
.about > summary::-webkit-details-marker { display: none; }
.about > summary:hover { color: var(--ocean-2); }
/* attention badge — draws the eye to open the help panel; hidden once open */
.about-badge {
font-family: var(--mono); font-size: 10.5px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.1em;
color: #fff; background: var(--seal); border-radius: 999px; padding: 3px 10px; flex: none;
animation: badgePulse 2.2s ease-out infinite;
}
@keyframes badgePulse {
0% { box-shadow: 0 0 0 0 rgba(226, 59, 46, 0.45); }
70% { box-shadow: 0 0 0 9px rgba(226, 59, 46, 0); }
100% { box-shadow: 0 0 0 0 rgba(226, 59, 46, 0); }
}
@media (prefers-reduced-motion: reduce) { .about-badge { animation: none; } }
.about-hint { font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--muted); }
.about[open] .about-badge, .about[open] .about-hint { display: none; }
@media (max-width: 680px) { .about-hint { display: none; } }
.about .about-toggle {
margin-left: auto; width: 9px; height: 9px; flex: none;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(45deg); transition: transform .2s;
}
.about[open] .about-toggle { transform: rotate(-135deg); }
.about-body { padding: 0 clamp(16px, 2.6vw, 28px) clamp(16px, 2.6vw, 24px); }
.about-body p { color: var(--muted); font-size: 14.5px; max-width: 76ch; }
.about-body > p:first-child { color: var(--ink); font-size: 15.5px; }
.about-body code { font-family: var(--mono); font-size: 12px; color: var(--ocean-2); background: rgba(27,65,97,.06); padding: 1px 5px; border-radius: 5px; }
.about-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 18px 0 6px; }
.about-grid h4 { font-family: var(--disp); font-weight: 600; font-size: 14px; color: var(--ink); margin-bottom: 5px; }
.about-grid p { font-size: 13.5px; }
.about-note { font-family: var(--mono); font-size: 12px; margin-top: 10px; }
@media (max-width: 680px) { .about-grid { grid-template-columns: 1fr; } }
/* Cards */
.card {
background: var(--paper); border: 1px solid var(--line);
border-radius: var(--radius); padding: 22px;
border-radius: var(--radius); padding: clamp(16px, 2.6vw, 28px);
}
.card h2 { font-family: var(--disp); font-weight: 600; font-size: clamp(20px, 3vw, 26px); color: var(--ink); margin-bottom: 4px; }
.card .hint { color: var(--muted); font-size: 14px; margin-bottom: 16px; }
.card h2 { font-family: var(--disp); font-weight: 600; font-size: clamp(20px, 2.6vw, 27px); color: var(--ink); margin-bottom: 4px; }
.card .hint { color: var(--muted); font-size: 14px; margin-bottom: 18px; max-width: 70ch; }
.card .hint code { font-family: var(--mono); font-size: 12.5px; color: var(--ocean-2); background: rgba(27,65,97,0.06); padding: 1px 5px; border-radius: 5px; }
/* Form fields */
.field { display: flex; flex-direction: column; gap: 6px; }
.fields { display: grid; grid-template-columns: 1.5fr 1fr; gap: 14px; align-items: end; }
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.field label { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
input[type="text"], input[type="password"] {
font-family: var(--mono); font-size: 15px; color: var(--ink);
background: #fff; border: 1px solid var(--line); border-radius: 10px;
padding: 11px 13px; width: 100%; outline: none;
padding: 12px 14px; width: 100%; outline: none;
transition: border-color .15s, box-shadow .15s;
}
input:focus { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97, 0.12); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-top: 16px; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
display: inline-flex; align-items: center; gap: 8px; cursor: pointer; white-space: nowrap;
font-family: var(--disp); font-weight: 600; font-size: 14px;
background: var(--ocean); color: var(--cream);
border: 1px solid var(--ocean); border-radius: 999px; padding: 10px 20px;
transition: transform .15s, background .15s;
transition: transform .15s, background .15s, border-color .15s;
}
.btn:hover { transform: translateY(-1px); background: var(--deep); border-color: var(--deep); }
.btn.ghost { background: transparent; color: var(--ocean); }
.btn.ghost:hover { background: rgba(27, 65, 97, 0.06); color: var(--ocean); }
.btn:active { transform: none; }
.btn.ghost { background: transparent; color: var(--ocean); }
.btn.ghost:hover { background: rgba(27, 65, 97, 0.06); color: var(--ocean); border-color: var(--ocean); }
.btn.small { padding: 7px 15px; font-size: 13px; }
/* Secret rendering: real text in the DOM, visually masked. Click toggles. */
.secret-line { margin-top: 16px; display: flex; align-items: baseline; gap: 12px; min-height: 28px; }
.secret-line .tag { font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; }
.secret {
font-family: "text-security-disc", var(--mono);
-webkit-text-security: disc;
font-size: 19px; letter-spacing: 0.06em; color: var(--ink);
cursor: pointer; word-break: break-all; user-select: text;
border-bottom: 1px dashed var(--line);
/* Quick-gen result row */
.result {
display: flex; align-items: center; flex-wrap: wrap; gap: 12px;
margin-top: 20px; padding: 14px 16px; border: 1px solid var(--line);
border-radius: 12px; background: #fff; min-height: 58px;
}
.secret.revealed { font-family: var(--mono); -webkit-text-security: none; color: var(--ocean); }
.secret:empty::before { content: "—"; color: var(--muted); -webkit-text-security: none; }
.result .tag { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.result .actions { margin-left: auto; display: flex; gap: 8px; }
.result .len { font-family: var(--mono); font-size: 11px; color: var(--muted); }
/* Secret rendering: real text in the DOM, visually masked. Click toggles.
Mask via -webkit-text-security on the MONO font so each disc keeps the mono
advance width — gen tables stay aligned. (No letter-spacing, no disc webfont
here, both of which would break the column widths.) */
.secret {
font-family: var(--mono);
-webkit-text-security: disc;
font-size: 19px; color: var(--ink);
cursor: pointer; user-select: text; white-space: pre;
}
.secret.revealed { -webkit-text-security: none; color: var(--ocean); }
/* Firefox lacks -webkit-text-security: fall back to the disc webfont for masking
(single-line result only; the console table is a WebKit/Blink concern). */
@supports not (-webkit-text-security: disc) {
.secret { font-family: "text-security-disc", var(--mono); }
.secret.revealed { font-family: var(--mono); }
}
.secret:empty::before { content: "type a name + master"; color: var(--muted); font-size: 14px; -webkit-text-security: none; font-family: var(--body); cursor: default; }
/* Console */
.console-out {
font-family: var(--mono); font-size: 13.5px; line-height: 1.55;
background: #fff; border: 1px solid var(--line); border-radius: 10px;
padding: 14px; height: 320px; overflow-y: auto; white-space: pre-wrap; word-break: break-word;
padding: 14px; height: clamp(280px, 46vh, 560px);
overflow: auto; white-space: pre; /* no wrap — scroll left/right */
}
.console-out > div { min-width: max-content; }
.console-out .cmd { color: var(--ocean); font-weight: 500; }
.console-out .err { color: var(--seal); }
.console-out .muted { color: var(--muted); }
.console-out .secret { font-size: 13.5px; }
.console-out .secret { font-size: 13.5px; vertical-align: baseline; }
.console-in { display: flex; align-items: center; gap: 8px; margin-top: 10px;
background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 4px 12px; }
background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 4px 14px; }
.console-in:focus-within { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27,65,97,0.12); }
.console-in .prompt { font-family: var(--mono); color: var(--ocean); font-weight: 600; }
.console-in input { border: none; box-shadow: none; padding: 8px 0; background: transparent; }
.console-in input { border: none; box-shadow: none; padding: 9px 0; background: transparent; }
.console-in input:focus { box-shadow: none; }
.toast {
@@ -157,21 +214,22 @@ input:focus { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97,
display: none; align-items: center; justify-content: center; padding: 20px; z-index: 90;
}
.modal-bg.show { display: flex; }
.modal { background: var(--paper); border-radius: 16px; border: 1px solid var(--line);
width: 100%; max-width: 620px; padding: 22px; }
.modal { background: var(--paper); border-radius: 18px; border: 1px solid var(--line);
width: 100%; max-width: 680px; padding: 24px; }
.modal h3 { font-family: var(--disp); font-weight: 600; margin-bottom: 10px; }
.modal textarea {
width: 100%; height: 240px; resize: vertical; font-family: var(--mono); font-size: 13px;
border: 1px solid var(--line); border-radius: 10px; padding: 12px; outline: none;
width: 100%; height: 260px; resize: vertical; font-family: var(--mono); font-size: 13px;
border: 1px solid var(--line); border-radius: 10px; padding: 12px; outline: none; white-space: pre;
}
.modal textarea:focus { border-color: var(--ocean); }
.modal .row { display: flex; gap: 10px; margin-top: 14px; }
.foot { color: var(--muted); font-family: var(--mono); font-size: 12px; text-align: center; padding: 8px 0 40px; }
.foot a { color: var(--ocean-2); }
@media (max-width: 620px) {
.grid2 { grid-template-columns: 1fr; }
.nav { gap: 8px; }
.console-out { height: 260px; }
@media (max-width: 680px) {
.fields { grid-template-columns: 1fr; align-items: stretch; }
.nav { gap: 9px; height: 60px; }
.tagline { display: none; }
body { font-size: 15px; }
.result .actions { margin-left: 0; }
}