Compare commits

...

37 Commits

Author SHA1 Message Date
ok a31ae9669f helwasm: footer version badge + changelog overlay 2026-06-10 14:46:14 +02:00
Oleksandr Kozachuk db05834fd1 helwasm: cache-bust favicon links (?v=2) so the new key icon refreshes
The new key favicon is already live and correct (md5 verified); browsers cache
favicons aggressively and keep showing the old enso. A version query forces a re-fetch.
2026-06-10 11:30:18 +02:00
Oleksandr Kozachuk 1fd61e5fc4 helwasm: favicon = official enso + key (render from icon-source.svg) 2026-06-10 11:22:40 +02:00
Oleksandr Kozachuk a88f9f6c88 helwasm: show the wordmark in standalone app mode (back link gone = room) 2026-06-10 00:31:51 +02:00
Oleksandr Kozachuk 90dd2532b5 helwasm: Enter in the master field copies the result (like Copy) 2026-06-10 00:26:59 +02:00
Oleksandr Kozachuk 507335168c helwasm: hide back-to-Kaizenkodo link when launched as an installed app
In standalone/home-screen launch, leaving the app context feels wrong, so hide the
back link. Detected via navigator.standalone (older iOS) + display-mode:standalone
media query / html.standalone class.
2026-06-10 00:24:45 +02:00
Oleksandr Kozachuk b25408d283 helwasm: Enter in the name field jumps to the master field 2026-06-10 00:21:25 +02:00
Oleksandr Kozachuk 11476aecbc helwasm: shrink result font to fit one line on narrow screens
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.
2026-06-10 00:19:33 +02:00
Oleksandr Kozachuk fa0fd841b6 helwasm: shorten quick-gen hint (iphone); round out the About panel
Hint cut ~35% to two sentences. About 'Console' now notes form<->console share the
same catalog/session and that pb copies to the clipboard; About note mentions
home-screen install.
2026-06-10 00:13:57 +02:00
Oleksandr Kozachuk ebb09c7cb9 help: list every command (add keep, pb, and the other/help/quit line)
keep <n> (keep a listed ls/gen entry by its left-column index), pb <command> (copy a
command's output to the clipboard), and a final line for help / # comments / quit were
missing. cargo test green; rebuilt wasm.
2026-06-10 00:07:59 +02:00
Oleksandr Kozachuk eef2104f25 helwasm: let hint + About text fill the block (drop ch max-width caps) 2026-06-09 23:59:49 +02:00
Oleksandr Kozachuk 15245d027c helwasm: fix hint — Store files the entry (name + rules), not just the name
Store saves the full entry spec (name, length, mode, seq, date, parent), never the
password; the old wording said 'just the entry name', which contradicts the recall-
full-spec behaviour. Fixed the quick-gen hint and the About blurb.
2026-06-09 23:57:10 +02:00
Oleksandr Kozachuk 4c6884c947 helwasm: footer respects the body gutter (use padding-block)
The .foot padding shorthand zeroed the inline padding, overriding .wrap's gutter so
the footer text ran to the window edge. Use padding-block so it lines up with the
body cards.
2026-06-09 23:54:13 +02:00
Oleksandr Kozachuk 7c651d0bb5 pb: copy to the clipboard in the browser
Instead of erroring, the wasm `pb` branch now writes the inner command's data to the
host page's clipboard via navigator.clipboard (new hel_clipboard_write host hook).
Runs inside the Enter-keypress handler, so the clipboard user-gesture requirement is
met. Native pbcopy/xclip path unchanged.
2026-06-09 23:51:51 +02:00
Oleksandr Kozachuk 30fbc1caae helwasm: merge Store + Mark correct into one progressive button
The two buttons encoded different facts (catalog membership vs verified hash) but
looked alike. Combine into a single button that walks the states, each a real engine
command: Store (add) -> Correct (correct) -> Correct ✓ (uncorrect), and Stored ✓
(disabled) when filed but no master is typed. Shorter label: "Correct", not
"Mark correct". Help texts updated.
2026-06-09 23:45:31 +02:00
Oleksandr Kozachuk 2f55846f70 helwasm: Stored button matches Correct (filled ocean); refocus console after pass modal
- is-stored now uses the same filled-ocean look as is-correct, so both confirmed
  states read identically
- closing the pass overlay (Set or Cancel) returns focus to the command line
2026-06-09 23:31:46 +02:00
Oleksandr Kozachuk 05613813de parser: a mode must end at a word boundary
A single-letter mode was stolen from the middle of a longer word, so `gen testG r
now` mis-parsed as name=r, mode=n (the 'n' of 'now'), leaving 'ow' dangling and
failing with a trailing-input error. Require a non-alphanumeric after a mode so 'now'
parses as the date it is. Adds a regression test.
2026-06-09 23:24:57 +02:00
Oleksandr Kozachuk 0dd173f113 helwasm: store-on-correct, store button reflects existence, use stored spec
- new read-only export hel_entry(name): the entry's canonical stored form, or ""
- Mark correct now also stores the name (keeps the entry); unmark keeps the stored
  name and just drops the hash (wraps add/rm for an ephemeral entry so the right
  hash is removed)
- Store button shows "Stored ✓" (disabled, ocean outline) when the name is already
  in the catalog, "Store" otherwise
- when a typed name already exists, the field adopts the full stored spec on blur
  (real mode/seq/date/parent) instead of a default-extended parse
2026-06-09 23:12:48 +02:00
Oleksandr Kozachuk c60e6c8ce2 helwasm: colour is per name+master, not per root master
Mark correct now stores/checks the leaf's own SHA1(name||password) (correct <name>),
read from the leaf warning in the enc output. Changing the name (or mistyping the
master) drops back to black until that exact name+master is marked. Mark/unmark wraps
add/rm for an ephemeral (not Stored) entry.
2026-06-09 22:53:44 +02:00
Oleksandr Kozachuk 10a21d1f9d helwasm: hierarchical quick-gen, pure sugar over the engine
Quick form now drives the one shared engine through commands only, so it and the
console give identical results (form == console).

- password hook answers only the "/" prompt, so read_master climbs and computes
  every ^parent from a single root master (Req 4)
- new master in the field is authoritative via `unpass`; empty field reuses the
  stored session master, so later names generate without re-typing it (Req 1/2)
- `unpass` with no name now clears ALL cached masters (real command, not a private
  hook) so the form stays reproducible in the console
- result colour = root master marked correct (correct / / uncorrect /), derived
  live from the engine; black otherwise (Req 3)
- Mark-correct toggle button; click the master to reveal it; honest memory-only hint
- master stays in memory only, never written to storage; only the one-way correct
  hash is saved on the device

parser: bare-name forms now keep a trailing comment, and a ^-led token is never a
name, so `name ^parent` / `name MODE ^parent` link the parent instead of qpname
reading it as prefix+name (was silently mis-stored).
2026-06-09 22:45:44 +02:00
Oleksandr Kozachuk e25746d5f0 web: add an always-visible '← Kaizenkodo' back link to the main site
The brand only reloaded the tool and the byline was hidden on mobile, so there
was no easy way back. Add a pill back link (href='../') as the first header item,
shown on every width; drop the wordmark below 540px so it still fits on phones.
2026-06-09 21:38:14 +02:00
Oleksandr Kozachuk fc0e9bfafd web: clarify + shorten the Store/privacy hint (browser-only, never a server) 2026-06-08 23:11:58 +02:00
Oleksandr Kozachuk 1370e640fb web: drop em-dashes from style.css comments 2026-06-08 23:03:30 +02:00
Oleksandr Kozachuk 1005e5a8e2 web: forward-port the no-horizontal-scroll rules into source style.css (de-em-dashed comments) 2026-06-08 23:02:44 +02:00
Oleksandr Kozachuk 29c26ddda1 web: drop em-dashes from the help/UI copy, reword to not need them 2026-06-08 23:00:41 +02:00
Oleksandr Kozachuk 77352093c9 web: official ensō+key icon, Kaizenkodo link, installable home-screen app
- App icon = the official Kaizenkodo ensō (logo/current/exports/tight) with a key
  inside: cream bg for the home-screen/app icon, transparent for the header +
  favicons. Source: assets/enso-base.png + icon-source.svg / enso-mark.svg.
- Attribution: a 'by Kaizenkodo' header link and a footer line, both to kaizenkodo.no.
- PWA install: manifest (standalone, start_url/scope '../', maskable icons) + iOS
  metas (apple-mobile-web-app-capable/title/status-bar) so it adds to the iPhone
  home screen and runs as a standalone app.
2026-06-08 22:39:30 +02:00
Oleksandr Kozachuk b163cc440f web: stack the result row cleanly on phones
On narrow screens the result row (tag + password + len + buttons) wrapped
awkwardly. Reorder under @media: password on its own full-width line, then a
tidy meta line (result + len ........ Copy / Store).
2026-06-08 22:18:07 +02:00
Oleksandr Kozachuk be2078779d web: quick-gen result scales + wraps on small screens
The result password was a fixed 19px with white-space:pre, so it overflowed the
card on phones. Scope .result .secret to a dynamic font (clamp 13-19px, ~15px at
390px) that wraps (pre-wrap + overflow-wrap:anywhere + min-width:0). Desktop is
unchanged (fits one line at 19px); the console gen-table keeps its pre alignment.
2026-06-08 22:12:53 +02:00
Oleksandr Kozachuk 509a66a609 web: prevent iOS focus-zoom — inputs/textarea >= 16px
iOS Safari auto-zooms (and stays zoomed) when a focused input is under 16px.
Bumped the form inputs (15px) and modal textareas (13px) to 16px; the console
input inherits the base. No viewport user-scalable hack (keeps pinch-zoom).
2026-06-08 22:04:55 +02:00
Oleksandr Kozachuk db0eb9ebc4 web: stop browser autofill on the name + master fields
The master and pass-overlay inputs are now type=text with a CSS visual mask
(-webkit-text-security) instead of type=password, so the browser never treats
them as password fields — no autofill, no save prompt, no 'suggest strong
password'. Added autocomplete=off + autocapitalize/autocorrect off and
data-1p-ignore / data-lpignore / data-bwignore on the name + master + pass.
2026-06-08 21:59:16 +02:00
Oleksandr Kozachuk d44a54002b parser: default mode for a bare name is now R (six S/KEY words), not C
Matches the prefix-name default, the README, and the app's own help text (which
already says the default is six memorable words). Explicit modes are unaffected,
so existing catalogs round-trip unchanged.
2026-06-08 21:51:03 +02:00
Oleksandr Kozachuk 14d404744c web: fix mobile horizontal overflow
main is a grid; its items default to min-width:auto, so the console card (pre /
no-wrap lines) refused to shrink and pushed the whole page wider than the phone
screen, clipping the About panel text. Set grid-template-columns: minmax(0,1fr)
so cards shrink to the viewport and the console scrolls internally instead.
2026-06-08 21:41:35 +02:00
Oleksandr Kozachuk 349bbe1557 web: link RFC 2289 in the About panel 2026-06-08 21:24:59 +02:00
Oleksandr Kozachuk f8e8a9981a help: note the S/KEY (RFC 2289) heritage + xkcd 936 memorable-words idea
The default R mode is the six-word S/KEY form. Mention it in the help command
(CLI + web console) and the web About panel, with a link to xkcd 936.
2026-06-08 21:15:15 +02:00
Oleksandr Kozachuk ba884037b0 web: state the no-server/in-browser privacy clearly + self-host fonts
- Make the privacy promise true: bundle the fonts (Poppins/Inter/JetBrains Mono
  latin subsets + the text-security mask font) under helwasm/assets/fonts/ and
  drop the Google Fonts + jsDelivr links. The page now makes zero external
  requests once loaded — it talks to no third party.
- Say it plainly throughout the UI: a "Private by design" callout in the About
  panel, the meta description, the quick-gen and console hints, and the footer —
  nothing you type leaves the browser, no server, no account, no tracking.
2026-06-08 20:42:33 +02:00
Oleksandr Kozachuk 3031105000 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.
2026-06-08 20:11:24 +02:00
Oleksandr Kozachuk 03c5dadccb feat: WASM web app (browser LesS/KEY) backed by the hel core
Run the real hel core in the browser via WebAssembly, replacing the plain-JS
ooke.github.io/sk reimplementation so the web and the CLI share one generator.

- hel core: new storage module (native fs / wasm localStorage) backs
  init/source/dump/correct; the pipe and pb branches are gated out of wasm;
  the wasm editor is completed (EditorRef) and its password prompt no longer
  blocks the single JS thread (synchronous hel_get_password instead of a
  thread::sleep poll).
- helwasm: hel_load_script for bulk import, a panic hook in hel_init, dropped
  the ok_add / web-sys leftovers; build.sh runs wasm-bindgen into a committed
  pkg/ so GitHub Pages can serve the static dir with no CI.
- UI: rewritten index.html + style.css in the kaizenkodo.org style — responsive,
  masked-but-copyable passwords with click-to-reveal, a quick-generate card plus
  a full command console, localStorage persistence, and paste import/export.
2026-06-08 18:43:19 +02:00
36 changed files with 1921 additions and 282 deletions
+113 -80
View File
@@ -1,10 +1,7 @@
use regex::Regex;
use sha1::{Digest, Sha1};
use std::cmp::min;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::io::{BufWriter, Write};
use std::collections::HashSet;
use crate::parser::command_parser;
use crate::password::fix_password_recursion;
@@ -12,8 +9,22 @@ use crate::password::{Name, Password, PasswordRef};
use crate::repl::LKEval;
use crate::structs::{config_get, config_set, LKOut, Radix, CORRECT_FILE, DUMP_FILE};
use crate::utils::editor::password;
// call_cmd_with_input / get_cmd_args_from_command / get_copy_command_from_env are
// only used by the native (non-wasm) subprocess branches.
#[cfg_attr(target_arch = "wasm32", allow(unused_imports))]
use crate::utils::{call_cmd_with_input, get_cmd_args_from_command, get_copy_command_from_env, rnd};
// In the browser `pb` copies through the host page's clipboard (navigator.clipboard)
// instead of shelling out to pbcopy/xclip.
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::wasm_bindgen;
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = hel_clipboard_write)]
fn hel_clipboard_write(text: &str);
}
impl<'a> LKEval<'a> {
pub fn get_password(&self, name: &String) -> Option<PasswordRef> {
match self.state.lock().borrow().ls.get(name) {
@@ -214,18 +225,28 @@ impl<'a> LKEval<'a> {
let data = print.out.data();
print.out.copy_err(&out);
if data.len() > 0 {
let (copy_command, copy_cmd_args) = get_copy_command_from_env();
match call_cmd_with_input(&copy_command, &copy_cmd_args, &data) {
Ok(s) if s.len() > 0 => {
out.o(format!(
"Copied output with the command {}, and got following output:",
copy_command
));
out.o(s.trim().to_string());
}
Ok(_) => out.o(format!("Copied output with command {}", copy_command)),
Err(e) => out.e(format!("error: failed to copy: {}", e.to_string())),
};
// Clipboard copy shells out to pbcopy/xclip/tmux: native only.
// In the browser the page provides a Copy button instead.
#[cfg(target_arch = "wasm32")]
{
hel_clipboard_write(&data);
out.o(format!("Copied {} characters to the clipboard", data.chars().count()));
}
#[cfg(not(target_arch = "wasm32"))]
{
let (copy_command, copy_cmd_args) = get_copy_command_from_env();
match call_cmd_with_input(&copy_command, &copy_cmd_args, &data) {
Ok(s) if s.len() > 0 => {
out.o(format!(
"Copied output with the command {}, and got following output:",
copy_command
));
out.o(s.trim().to_string());
}
Ok(_) => out.o(format!("Copied output with command {}", copy_command)),
Err(e) => out.e(format!("error: failed to copy: {}", e.to_string())),
};
}
}
}
Err(e) => out.e(format!("error: failed to parse command {}: {}", command, e.to_string())),
@@ -234,31 +255,42 @@ impl<'a> LKEval<'a> {
pub fn cmd_source(&self, out: &LKOut, source: &String) -> bool {
out.o(format!("source {}", source));
let script = if source.trim().ends_with("|") {
let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) {
Ok(c) => c,
let script: String;
if source.trim().ends_with("|") {
// Loading from a command's output needs a subprocess: native only.
#[cfg(target_arch = "wasm32")]
{
out.e("error: pipe source is not available in the browser".to_string());
return false;
}
#[cfg(not(target_arch = "wasm32"))]
{
let (cmd, args) = match get_cmd_args_from_command(source.trim().trim_end_matches('|')) {
Ok(c) => c,
Err(e) => {
out.e(format!("error: failed to parse command {:?}: {}", source, e.to_string()));
return false;
}
};
script = match call_cmd_with_input(&cmd, &args, "") {
Ok(o) => o,
Err(e) => {
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
return false;
}
};
}
} else {
// File path on native; localStorage key in the browser.
let key = shellexpand::full(source).unwrap().into_owned();
script = match crate::storage::read(&key) {
Ok(script) => script,
Err(e) => {
out.e(format!("error: failed to parse command {:?}: {}", source, e.to_string()));
out.e(format!("error: failed to read {}: {}", source, e.to_string()));
return false;
}
};
match call_cmd_with_input(&cmd, &args, "") {
Ok(o) => o,
Err(e) => {
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
return false;
}
}
} else {
let script = shellexpand::full(source).unwrap().into_owned();
match std::fs::read_to_string(script) {
Ok(script) => script,
Err(e) => {
out.e(format!("error: failed to read file {}: {}", source, e.to_string()));
return false;
}
}
};
}
match command_parser::script(&script) {
Ok(cmd_list) => {
for cmd in cmd_list {
@@ -321,39 +353,37 @@ impl<'a> LKEval<'a> {
None => config_get("hel_dump").unwrap_or_else(|| DUMP_FILE.to_str().unwrap().to_string()),
};
let script = shellexpand::full(&script).unwrap().into_owned();
fn save_dump(data: &HashMap<Name, PasswordRef>, script: &String) -> std::io::Result<()> {
let file = fs::File::create(script)?;
let mut writer = BufWriter::new(file);
let mut vals = data.values().map(|v| v.clone()).collect::<Vec<PasswordRef>>();
vals.sort_by(|a, b| a.lock().borrow().name.cmp(&b.lock().borrow().name));
for pwd in vals {
writeln!(writer, "add {}", pwd.lock().borrow().to_string())?
}
Ok(())
}
if script.trim().starts_with("|") {
let (cmd, args) = match get_cmd_args_from_command(script.trim().trim_start_matches('|')) {
Ok(c) => c,
Err(e) => {
out.e(format!("error: failed to parse command {:?}: {}", script, e.to_string()));
return;
// Piping the dump to a command needs a subprocess: native only.
#[cfg(target_arch = "wasm32")]
{
out.e("error: pipe dump is not available in the browser".to_string());
}
#[cfg(not(target_arch = "wasm32"))]
{
let (cmd, args) = match get_cmd_args_from_command(script.trim().trim_start_matches('|')) {
Ok(c) => c,
Err(e) => {
out.e(format!("error: failed to parse command {:?}: {}", script, e.to_string()));
return;
}
};
let data = self.serialize_db();
self.show_dump_diff(out, &data);
let output = match call_cmd_with_input(&cmd, &args, data.as_str()) {
Ok(o) => o,
Err(e) => {
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
return;
}
};
self.state.lock().borrow_mut().last_dump = Some(data);
if output.len() > 0 {
out.e(format!("Passwords saved to command {} and got following output:", cmd));
out.o(output);
} else {
out.o(format!("Passwords saved to command {}", cmd));
}
};
let data = self.serialize_db();
self.show_dump_diff(out, &data);
let output = match call_cmd_with_input(&cmd, &args, data.as_str()) {
Ok(o) => o,
Err(e) => {
out.e(format!("error: failed to execute command {}: {}", cmd, e.to_string()));
return;
}
};
self.state.lock().borrow_mut().last_dump = Some(data);
if output.len() > 0 {
out.e(format!("Passwords saved to command {} and got following output:", cmd));
out.o(output);
} else {
out.o(format!("Passwords saved to command {}", cmd));
}
} else if script.trim() == "-" {
let mut vals = (&self.state.lock().borrow().db).values().map(|v| v.clone()).collect::<Vec<PasswordRef>>();
@@ -362,14 +392,15 @@ impl<'a> LKEval<'a> {
out.o(format!("add {}", pwd.lock().borrow().to_string()))
}
} else {
// File path on native; localStorage key in the browser.
let data = self.serialize_db();
self.show_dump_diff(out, &data);
// Bind first so the immutable borrow drops before the borrow_mut below.
let res = save_dump(&self.state.lock().borrow().db, &script);
match res {
// Trailing newline to match the historical file format (writeln per line).
let body = if data.is_empty() { String::new() } else { format!("{}\n", data) };
match crate::storage::write(&script, &body) {
Ok(()) => {
self.state.lock().borrow_mut().last_dump = Some(data);
out.o(format!("Passwords saved to file {}", script));
out.o(format!("Passwords saved to {}", script));
}
Err(e) => out.e(format!("error: failed to dump passwords to {}: {}", script, e.to_string())),
};
@@ -434,11 +465,13 @@ impl<'a> LKEval<'a> {
None => return,
};
fn load_lines() -> std::io::Result<HashSet<String>> {
let file = fs::File::open(CORRECT_FILE.to_str().unwrap())?;
let reader = BufReader::new(file);
let content = crate::storage::read(CORRECT_FILE.to_str().unwrap())?;
let mut lines = HashSet::new();
for line in reader.lines() {
lines.insert(line?.trim().to_owned());
for line in content.lines() {
let line = line.trim();
if !line.is_empty() {
lines.insert(line.to_owned());
}
}
Ok(lines)
}
@@ -469,12 +502,12 @@ impl<'a> LKEval<'a> {
data.remove(&encpwd);
}
fn save_lines(data: &HashSet<String>) -> std::io::Result<()> {
let file = fs::File::create(CORRECT_FILE.to_str().unwrap())?;
let mut writer = BufWriter::new(file);
let mut content = String::new();
for entry in data {
writeln!(writer, "{}", entry)?;
content.push_str(entry);
content.push('\n');
}
Ok(())
crate::storage::write(CORRECT_FILE.to_str().unwrap(), &content)
}
match save_lines(&data) {
Ok(()) => out.o(format!(
+1
View File
@@ -11,5 +11,6 @@ pub mod parser;
pub mod password;
pub mod repl;
pub mod skey;
pub mod storage;
pub mod structs;
pub mod utils;
+58 -7
View File
@@ -37,13 +37,22 @@ 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() pc:comment()?
{ Password::new(Some(pr), pn, pl, pm, 99, Date::now(), pc) }
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) }
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())? {?
rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() pc:comment()?
{ Password::new(None, pn, pl, pm, 99, Date::now(), pc) }
// prefix + name (+ optional comment) -> defaults mode R, seq 99, date now.
// A `^parent` token is never a name, so a `^`-leading second word fails here and
// falls through to qname, which keeps it as the comment (the parent marker).
rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:$(!"^" ['!'..='~']+) pc:comment()?
{ Password::new(Some(pr), pn.to_string(), None, Mode::Regular, 99, Date::now(), pc) }
// name (+ optional comment) -> e.g. `github` or `github ^work`
rule qname() -> Password = pn:word() pc:comment()?
{ Password::new(None, pn, None, Mode::Regular, 99, Date::now(), pc) }
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") }
}
@@ -76,7 +85,10 @@ peg::parser! {
_ => Err("unknown mode"),
}
}
rule mode() -> Mode = m:(umode() / rmode()) { m }
// A mode is a whole token: it must not be followed by another letter/digit, or
// a single letter would be stolen from a longer word (e.g. the `n` of `now`,
// making `name r now` mis-parse as name=`r`, mode=`n` instead of date=`now`).
rule mode() -> Mode = m:(umode() / rmode()) !['0'..='9' | 'a'..='z' | 'A'..='Z'] { m }
rule noop_cmd() -> Command<'input> = ("#" [' '..='~']*)? { Command::Noop }
rule help_cmd() -> Command<'input> = "help" { Command::Help }
@@ -101,7 +113,7 @@ peg::parser! {
rule pass_cmd() -> Command<'input> = p:(pass_long_cmd() / pass_short_cmd()) { p }
rule correct_cmd() -> Command<'input> = "correct" _ name:word() { Command::Correct(name) }
rule uncorrect_cmd() -> Command<'input> = "uncorrect" _ name:word() { Command::Uncorrect(name) }
rule unpass_cmd() -> Command<'input> = "unpass" _ name:word() { Command::UnPass(name) }
rule unpass_cmd() -> Command<'input> = "unpass" name:(_ w:word() { w })? { Command::UnPass(name) }
rule enc_cmd() -> Command<'input> = "enc" _ name:word() { Command::Enc(name) }
rule rm_cmd() -> Command<'input> = "rm" _ name:word() { Command::Rm(name) }
rule comment_cmd() -> Command<'input> = "comment" _ name:word() c:comment()? { Command::Comment(name, c) }
@@ -239,6 +251,45 @@ add t3 C 99 2022-12-14
);
}
#[test]
fn parse_short_parent_test() {
// `name ^parent` (no mode/date): name is the name, ^parent stays in the comment
// for fix_hierarchy — it must NOT be read as prefix+name.
let p = command_parser::name("x ^acc").unwrap();
assert_eq!(p.name, "x");
assert_eq!(p.prefix, None);
assert_eq!(p.comment, Some("^acc".to_string()));
// `name [len]mode ^parent` keeps the parent in the comment too
let p2 = command_parser::name("x 20R ^acc").unwrap();
assert_eq!(p2.name, "x");
assert_eq!(p2.length, Some(20));
assert_eq!(p2.mode, Mode::Regular);
assert_eq!(p2.comment, Some("^acc".to_string()));
// a real prefix + name (second word not ^-led) still parses as prefix+name
let p3 = command_parser::name("#W9 github").unwrap();
assert_eq!(p3.prefix, Some("#W9".to_string()));
assert_eq!(p3.name, "github");
assert_eq!(p3.comment, None);
}
#[test]
fn parse_mode_word_boundary_test() {
// `now` must be the date, not mode `n` + leftover `ow`: name=testG mode=Regular.
let g = command_parser::name("testG r now").unwrap();
assert_eq!(g.name, "testG");
assert_eq!(g.prefix, None);
assert_eq!(g.mode, Mode::Regular);
// the whole gen command parses with no trailing-input error (so `now` was
// consumed as the date, not as a mode that left `ow` dangling)
assert!(command_parser::cmd("gen testG r now").is_ok());
assert!(command_parser::cmd("gen testG R now").is_ok());
// a length+mode token still works right up to a word boundary
let h = command_parser::name("github 20R").unwrap();
assert_eq!(h.name, "github");
assert_eq!(h.length, Some(20));
assert_eq!(h.mode, Mode::Regular);
}
#[test]
fn parse_password_test() {
assert_eq!(
+36 -2
View File
@@ -133,15 +133,49 @@ impl<'a> LKEval<'a> {
Command::Set(key, value) => { to_history = false; self.cmd_set(&out, key, value); }
Command::Pass(name, None) => self.cmd_pass(&out, &name, &None),
Command::Pass(name, pass) => { to_history = false; self.cmd_pass(&out, &name, &pass); },
Command::UnPass(name) => match self.state.lock().borrow_mut().secrets.remove(name) {
Command::UnPass(Some(name)) => match self.state.lock().borrow_mut().secrets.remove(name) {
Some(_) => out.o(format!("Removed saved password for {}", name)),
None => out.e(format!("error: saved password for {} not found", name)),
},
Command::UnPass(None) => {
self.state.lock().borrow_mut().secrets.clear();
out.o("forgot all cached masters".to_string());
},
Command::Correct(name) => self.cmd_correct(&out, name, true, None),
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 - S/KEY (RFC 2289) deterministic passwords from your master + the entry\n",
"name; nothing secret is stored. The default mode is six short, memorable words\n",
"(the \"correct horse battery staple\" idea from xkcd 936).\n",
"\n",
"entries\n",
" add <name> [len][mode] [seq] [date] [text] [^parent] define an entry\n",
" keep <n> keep list entry n (left column of ls/gen) in the catalog\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",
" pb <command> run a command and copy its output to the clipboard\n",
" pass <name> [pw] cache a master / override for an entry's subtree\n",
" unpass [name] forget a cached password (unpass / = root; unpass = all)\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",
"other\n",
" help this text # text a comment (ignored) quit exit (CLI)\n",
"\n",
"modes R words C camel N hyphenated H hex B base64 D decimal (U.. = UPPER)\n",
" R is the six-word S/KEY form: memorable, pronounceable, easy to type anywhere."
).to_string());
}
Command::Mv(name, folder) => self.cmd_mv(&out, &name, &folder),
Command::Error(error) => {
+48
View File
@@ -0,0 +1,48 @@
//! Key -> value persistence with a per-target backend.
//!
//! - Native: the key is a filesystem path; backed by `std::fs`.
//! - WASM: the key is a localStorage key; backed by JS imports
//! `hel_storage_get` / `hel_storage_set` (provided by the host page).
//!
//! This lets the same command code (`init`, `source`, `dump`/`save`, `correct`)
//! persist to files on the CLI and to the browser's localStorage on the web,
//! with no per-call-site branching.
use std::io;
#[cfg(not(target_arch = "wasm32"))]
pub fn read(key: &str) -> io::Result<String> {
std::fs::read_to_string(key)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn write(key: &str, data: &str) -> io::Result<()> {
std::fs::write(key, data)
}
#[cfg(target_arch = "wasm32")]
mod imp {
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = hel_storage_get)]
pub fn get(key: &str) -> Option<String>;
#[wasm_bindgen(js_name = hel_storage_set)]
pub fn set(key: &str, val: &str);
}
}
#[cfg(target_arch = "wasm32")]
pub fn read(key: &str) -> io::Result<String> {
match imp::get(key) {
Some(v) => Ok(v),
None => Err(io::Error::new(io::ErrorKind::NotFound, "key not found in localStorage")),
}
}
#[cfg(target_arch = "wasm32")]
pub fn write(key: &str, data: &str) -> io::Result<()> {
imp::set(key, data);
Ok(())
}
+4 -3
View File
@@ -97,7 +97,7 @@ pub enum Command<'a> {
Enc(Name),
Gen(u32, PasswordRef),
Pass(Name, Option<String>),
UnPass(Name),
UnPass(Option<Name>),
Correct(Name),
Uncorrect(Name),
PasteBuffer(String),
@@ -153,7 +153,8 @@ impl<'a> std::fmt::Display for Command<'a> {
Command::Gen(a, b) => write!(f, "gen{} {}", a, b.lock().borrow().to_string().trim()),
Command::Pass(a, None) => write!(f, "pass {}", a),
Command::Pass(a, Some(b)) => write!(f, "pass {} {}", a, b),
Command::UnPass(s) => write!(f, "unpass {}", s),
Command::UnPass(None) => write!(f, "unpass"),
Command::UnPass(Some(s)) => write!(f, "unpass {}", s),
Command::Correct(s) => write!(f, "correct {}", s),
Command::Uncorrect(s) => write!(f, "uncorrect {}", s),
Command::PasteBuffer(s) => write!(f, "pb {}", s),
@@ -392,7 +393,7 @@ pub fn init() -> Option<LKRead> {
let lk = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
let editor = Editor::new();
match std::fs::read_to_string(INIT_FILE.to_str().unwrap()) {
match crate::storage::read(INIT_FILE.to_str().unwrap()) {
Ok(script) => match command_parser::script(&script) {
Ok(cmd_list) => {
for cmd in cmd_list {
+15 -14
View File
@@ -150,25 +150,32 @@ pub mod editor {
#[cfg(target_arch = "wasm32")]
pub mod editor {
use crate::structs::LKErr;
use parking_lot::Mutex;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
// Mirror the unix editor's contract so repl.rs (which holds an `EditorRef`
// and calls `.lock()`) compiles unchanged under wasm.
pub type EditorRef = Arc<Mutex<Editor>>;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_name = hel_read_password)]
fn extern_read_password(prompt: &str);
#[wasm_bindgen(js_name = hel_current_password)]
fn extern_current_password() -> Option<String>;
// Synchronous: the host page returns the current master-password value.
// (The old read/poll pair used thread::sleep, which deadlocks the single
// browser thread — never use blocking polling under wasm.)
#[wasm_bindgen(js_name = hel_get_password)]
fn extern_get_password(prompt: &str) -> String;
}
#[derive(Debug)]
pub struct Editor {
#[allow(dead_code)]
history: Vec<String>,
}
impl Editor {
pub fn new() -> Self {
Self { history: vec![] }
pub fn new() -> EditorRef {
Arc::new(Mutex::new(Self { history: vec![] }))
}
pub fn clear_history(&mut self) {
@@ -193,13 +200,7 @@ pub mod editor {
}
pub fn password(prompt: String) -> std::io::Result<String> {
extern_read_password(&prompt);
loop {
match extern_current_password() {
Some(p) => return Ok(p),
None => std::thread::sleep(std::time::Duration::from_millis(100)),
}
}
Ok(extern_get_password(&prompt))
}
}
+1 -4
View File
@@ -16,7 +16,4 @@ hel = { version = "0.1.0", path = "../hel" }
lazy_static = "1.4.0"
wasm-bindgen = "0.2.83"
parking_lot = "0.12.1"
[dependencies.web-sys]
version = "0.3.4"
features = [ 'Document' ]
console_error_panic_hook = "0.1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<image href="enso-base.png" x="25.6" y="18.8" width="460.8" height="474.4"/>
<g><circle cx="256" cy="189" r="30" fill="none" stroke="#1b4161" stroke-width="22"/><rect x="246" y="211" width="20" height="120" rx="4" fill="#1b4161"/><rect x="266" y="288" width="28" height="13" rx="3" fill="#1b4161"/><rect x="266" y="310" width="18" height="13" rx="3" fill="#1b4161"/></g>
</svg>

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" fill="#f4f0e8"/>
<image href="enso-base.png" x="25.6" y="18.8" width="460.8" height="474.4"/>
<g><circle cx="256" cy="189" r="30" fill="none" stroke="#1b4161" stroke-width="22"/><rect x="246" y="211" width="20" height="120" rx="4" fill="#1b4161"/><rect x="266" y="288" width="28" height="13" rx="3" fill="#1b4161"/><rect x="266" y="310" width="18" height="13" rx="3" fill="#1b4161"/></g>
</svg>

After

Width:  |  Height:  |  Size: 514 B

+25
View File
@@ -0,0 +1,25 @@
{
"name": "LesS/KEY password generator",
"short_name": "LesS/KEY",
"description": "A password manager that stores nothing and runs entirely in your browser. A free tool by Kaizenkodo.",
"id": "../",
"start_url": "../",
"scope": "../",
"display": "standalone",
"theme_color": "#0a2540",
"background_color": "#f4f0e8",
"icons": [
{
"src": "./icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Build the helwasm web bundle into helwasm/pkg/ (committed so GitHub Pages can
# serve the static dir directly, no CI). Requires the wasm32 target and a
# wasm-bindgen CLI matching the wasm-bindgen crate version:
# rustup target add wasm32-unknown-unknown
# cargo install wasm-bindgen-cli --version <crate version>
set -e
cd "$(dirname "$0")/.."
cargo build --target wasm32-unknown-unknown -p helwasm --release
wasm-bindgen --target web --out-dir helwasm/pkg \
target/wasm32-unknown-unknown/release/helwasm.wasm
echo "built helwasm/pkg/ (helwasm.js + helwasm_bg.wasm)"
+700 -162
View File
@@ -1,172 +1,710 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
html {
height: 100%;
}
body {
min-height: 100%;
}
html,
body {
background-color: #000;
width: 100%;
margin: 0;
padding: 0;
font-family: monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.terminal {
height: 100%;
width: 100%;
margin: 0 auto;
color: #fff;
overflow-y: scroll;
padding: 20px;
}
.prompt {
color: #0f0;
}
.form {
display: flex;
width: 100vw;
margin: 0 auto;
}
.input {
background-color: transparent;
border: none;
color: #fff;
outline: none;
flex-grow: 1;
}
.input::placeholder {
color: #666;
}
label {
width: 100%;
white-space: nowrap;
display: inline-flex;
}
.terminal p,
.prompt,
.input {
font-family: monospace;
font-size: 16px;
white-space: pre-wrap;
}
#passwordPrompt {
position: fixed;
top: 50%;
left: 50%;
tranform: translate(-50%, -50%);
backrground-color: white;
border: 1px solid black;
padding: 20px;
}
</style>
<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?v=2" />
<link rel="icon" type="image/png" sizes="16x16" href="./assets/favicon-16x16.png?v=2" />
<link rel="icon" href="./assets/favicon.ico?v=2" sizes="any" />
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png?v=2" />
<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>
<div id="root"></div>
<script type="module">
import init, { hel_init, hel_command } from "./pkg/helwasm.js";
init().then(() => {
window.hel = {
hel_init: hel_init,
hel_command: hel_command,
}
});
</script>
<script type="text/babel">
function Terminal() {
const prompt = "> ";
const [input, setInput] = React.useState("");
const [output, setOutput] = React.useState([]);
const [passwordPrompt, setPasswordPrompt] = React.useState(undefined);
const [passwordInput, setPasswordInput] = React.useState("");
const outputRef = React.useRef(null);
<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>
React.useEffect(() => {
outputRef.current.scrollIntoView({ behaviour: "smooth" });
}, [output]);
function handleInput(event) {
setInput(event.target.value);
}
function handleSubmit(event) {
event.preventDefault();
const result = executeCommand(input);
setOutput([...output, { prompt: prompt, command: input }, { prompt: "", command: result }]);
setInput("");
}
function handlePasswordInput(event) {
setPasswordInput(event.target.value);
}
function handlePasswordSubmit(event) {
window.hel_current_password_value = passwordInput;
setPasswordPrompt(undefined);
setPasswordInput("");
}
function helReadPassword(prompt) {
window.hel_current_password_value = null;
setPasswordPrompt(prompt);
}
window.hel_read_password = helReadPassword;
function helCurrentPassword(prompt) {
return window.hel_current_password_value;
}
window.hel_current_password = helCurrentPassword;
function executeCommand(command) {
return window.hel.hel_command(command);
}
return (
<div className="terminal">
{output.map(({ prompt, command }, index) => (<p key={index}>{prompt && (<span className="prompt">{prompt}</span>)}{command}</p>))}
<form onSubmit={handleSubmit}>
<label>
<span className="prompt" ref={outputRef}>{prompt}</span>
<input
type="text"
value={input}
onChange={handleInput}
className="input"
autoFocus
/>
</label>
</form>
{passwordPrompt !== undefined && (<form onSubmit={handlePasswordSubmit}><label><span className="prompt">{passwordPrompt}</span><input type="password" value={passwordInput} onChange={handlePasswordInput} className="input" autoFocus /></label></form>)}
<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 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">&gt;</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.
<button class="ver" id="verBtn" type="button" title="What's new — changelog"></button>
</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>
<!-- Changelog overlay (opened from the footer version badge) -->
<div class="modal-bg" id="changelogModal">
<div class="modal">
<h3>What's new</h3>
<p class="hint">Recent changes to LesS/KEY, newest first.</p>
<div class="changelog" id="changelogBody"></div>
<div class="row">
<button class="btn ghost" id="changelogClose">Close</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";
// Single source of truth for the displayed version AND the changelog dialog.
// Newest first: CHANGELOG[0].version is the footer badge and gets the "Latest"
// flag. To cut a release, bump the top version and prepend an entry. Each change
// is { tag: "New" | "Improved", text }; `text` may hold inline <code> for command
// names (authored/trusted content, rendered as HTML). `title` = the release theme.
const CHANGELOG = [
{ version: "1.0.0", date: "2026-06-10", title: "Keyboard-first, and installable", changes: [
{ tag: "New", text: "Press Enter in the name field to jump to the master; Enter in the master generates and copies." },
{ tag: "New", text: "Install LesS/KEY to your home screen and run it as a standalone app." },
{ tag: "Improved", text: "Fresh ensō + key icon; the result shrinks to stay on one line on narrow phones." },
] },
{ version: "0.9.0", date: "2026-06-09", title: "A smarter catalog", changes: [
{ tag: "New", text: "Quick password follows <code>^parent</code> chains, all computed from your one master." },
{ tag: "New", text: "<code>pb</code> copies a command's output to the clipboard; help now lists every command." },
{ tag: "Improved", text: "One progressive Store → Correct button; verified results show in colour." },
] },
{ version: "0.8.0", date: "2026-06-08", title: "Public launch", changes: [
{ tag: "New", text: "Runs entirely in your browser via WebAssembly — nothing you type is ever sent to a server." },
{ tag: "New", text: "Self-hosted fonts, installable and mobile-friendly; a bare name makes six memorable S/KEY words by default." },
] },
];
// When launched as an installed app (home-screen / standalone), hide the
// "back to Kaizenkodo" link — leaving the app context would feel wrong there.
// navigator.standalone covers older iOS; the media query covers the rest.
if (window.navigator.standalone === true ||
(window.matchMedia && window.matchMedia("(display-mode: standalone)").matches)) {
document.documentElement.classList.add("standalone");
}
const container = document.getElementById('root');
const root = ReactDOM.createRoot(container);
root.render(<Terminal />);
// ---- 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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">&gt; ' + 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) || "&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 = "";
$("#cin").focus(); // return to the command line after the overlay closes
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>");
}
// ---- changelog overlay ----
// Render once from CHANGELOG (newest first). Each entry: a head with the
// version + date, then a bullet list of changes. The <code> in a change is
// authored, so innerHTML is safe here (no user input ever reaches this).
function renderChangelog() {
const body = $("#changelogBody");
if (!body || body.childElementCount) return; // build once
CHANGELOG.forEach((rel, i) => {
const entry = document.createElement("div");
entry.className = "cl-entry";
const head = document.createElement("div");
head.className = "cl-head";
const ver = document.createElement("span");
ver.className = "cl-ver";
ver.textContent = "v" + rel.version;
const date = document.createElement("span");
date.className = "cl-date";
date.textContent = rel.date;
head.append(ver, date);
if (i === 0) { // flag the newest release
const latest = document.createElement("span");
latest.className = "cl-latest";
latest.textContent = "Latest";
head.appendChild(latest);
}
entry.appendChild(head);
if (rel.title) {
const title = document.createElement("div");
title.className = "cl-title";
title.textContent = rel.title;
entry.appendChild(title);
}
const ul = document.createElement("ul");
for (const c of rel.changes) {
const li = document.createElement("li");
const tag = document.createElement("span");
tag.className = "cl-tag cl-tag-" + c.tag.toLowerCase();
tag.textContent = c.tag;
const text = document.createElement("span");
text.className = "cl-text";
text.innerHTML = c.text;
li.append(tag, text);
ul.appendChild(li);
}
entry.appendChild(ul);
body.appendChild(entry);
});
}
// ---- 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
// Enter in the name field jumps to the master field (its blur then normalises
// the name and regenerates). Works with the iOS keyboard "return" key too.
$("#qname").addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); $("#master").focus(); }
});
// 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"));
// Enter in the master field copies the result, like pressing Copy (the keydown
// gesture lets the clipboard write through). Generate first so it's current.
$("#master").addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
quickGen(false);
if (lastSecret) copyText(lastSecret); else toast("Nothing to copy");
});
$("#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);
// changelog overlay: footer badge shows the top version and opens the dialog
const changelog = $("#changelogModal");
$("#verBtn").textContent = "v" + CHANGELOG[0].version;
renderChangelog();
$("#verBtn").onclick = () => changelog.classList.add("show");
$("#changelogClose").onclick = () => changelog.classList.remove("show");
// Esc closes the changelog (scoped — leaves the other modals' behaviour alone).
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && changelog.classList.contains("show")) changelog.classList.remove("show");
});
// 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>
+82
View File
@@ -0,0 +1,82 @@
/* tslint:disable */
/* eslint-disable */
/**
* Run a single hel command line and return its combined output.
*/
export function hel_command(cmd: string): string;
/**
* Look up an entry by its exact name and return its canonical stored form
* (`name [len]mode seq date comment ^parent`), or "" if it is not in the catalog.
* Read-only. The UI uses this to detect "already stored" and to use the real stored
* spec (its mode/seq/date/parent) instead of the bare name the user typed.
*/
export function hel_entry(name: string): string;
/**
* Call once at page load: routes Rust panics to the browser console with a
* readable message + stack instead of an opaque "unreachable" trap.
*/
export function hel_init(): void;
/**
* 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.
*/
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_entry: (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 hel_init: () => void;
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;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
+391
View File
@@ -0,0 +1,391 @@
/* @ts-self-types="./helwasm.d.ts" */
/**
* Run a single hel command line and return its combined output.
* @param {string} cmd
* @returns {string}
*/
export function hel_command(cmd) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(cmd, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.hel_command(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);
}
}
/**
* Look up an entry by its exact name and return its canonical stored form
* (`name [len]mode seq date comment ^parent`), or "" if it is not in the catalog.
* Read-only. The UI uses this to detect "already stored" and to use the real stored
* spec (its mode/seq/date/parent) instead of the bare name the user typed.
* @param {string} name
* @returns {string}
*/
export function hel_entry(name) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.hel_entry(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);
}
}
/**
* Call once at page load: routes Rust panics to the browser console with a
* readable message + stack instead of an opaque "unreachable" trap.
*/
export function hel_init() {
wasm.hel_init();
}
/**
* 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.
* @param {string} script
* @returns {string}
*/
export function hel_load_script(script) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(script, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.hel_load_script(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 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,
__wbg___wbindgen_throw_1506f2235d1bdba0: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
console.error(getStringFromWasm0(arg0, arg1));
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
},
__wbg_getTime_00b3f7db575e4ef5: function(arg0) {
const ret = arg0.getTime();
return ret;
},
__wbg_getTimezoneOffset_08e2892156231088: function(arg0) {
const ret = arg0.getTimezoneOffset();
return ret;
},
__wbg_hel_clipboard_write_efa8177dd67c65ec: function(arg0, arg1) {
hel_clipboard_write(getStringFromWasm0(arg0, arg1));
},
__wbg_hel_get_password_271a2beac04c29db: function(arg0, arg1, arg2) {
const ret = hel_get_password(getStringFromWasm0(arg1, arg2));
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_hel_rnd_range_d01857a65a482c6d: function(arg0, arg1) {
const ret = hel_rnd_range(arg0 >>> 0, arg1 >>> 0);
return ret;
},
__wbg_hel_storage_get_9915f302e24bdacb: function(arg0, arg1, arg2) {
const ret = hel_storage_get(getStringFromWasm0(arg1, arg2));
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg_hel_storage_set_78052521669832fa: function(arg0, arg1, arg2, arg3) {
hel_storage_set(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
},
__wbg_new_0_445c13a750296eb6: function() {
const ret = new Date();
return ret;
},
__wbg_new_227d7c05414eb861: function() {
const ret = new Error();
return ret;
},
__wbg_new_6d75fd236f920a62: function(arg0) {
const ret = new Date(arg0);
return ret;
},
__wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) {
const ret = arg1.stack;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbindgen_cast_0000000000000001: function(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./helwasm_bg.js": import0,
};
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('helwasm_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const hel_command: (a: number, b: number) => [number, number];
export const hel_entry: (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 hel_init: () => void;
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;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_start: () => void;
+62 -5
View File
@@ -1,22 +1,79 @@
use hel::lk::{LK, LKRef};
use hel::repl::LKRead;
use hel::utils::editor::Editor;
use std::sync::Arc;
use hel::parser::command_parser;
use hel::repl::{LKEval, LKRead};
use hel::structs::LKOut;
use hel::utils::editor::{password, Editor};
use parking_lot::ReentrantMutex;
use std::cell::RefCell;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
lazy_static! {
static ref STATE: LKRef = Arc::new(ReentrantMutex::new(RefCell::new(LK::new())));
}
#[allow(dead_code)]
/// Run a single hel command line and return its combined output.
#[wasm_bindgen]
pub fn hel_command(cmd: String) -> String {
let editor = Editor::new();
let mut lkread = LKRead::new(editor, "> ".to_string(), STATE.clone());
lkread.input = Some(cmd.to_string());
lkread.input = Some(cmd);
let lkeval = lkread.read();
let lkprint = lkeval.eval();
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(),
}
}
/// Look up an entry by its exact name and return its canonical stored form
/// (`name [len]mode seq date comment ^parent`), or "" if it is not in the catalog.
/// Read-only. The UI uses this to detect "already stored" and to use the real stored
/// spec (its mode/seq/date/parent) instead of the bare name the user typed.
#[wasm_bindgen]
pub fn hel_entry(name: String) -> String {
let cell = STATE.lock();
let lk = cell.borrow();
match lk.db.get(&name).or_else(|| lk.ls.get(&name)) {
Some(p) => p.lock().borrow().to_string().trim().to_string(),
None => String::new(),
}
}
/// 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.
#[wasm_bindgen]
pub fn hel_load_script(script: String) -> String {
let out = LKOut::new();
match command_parser::script(&script) {
Ok(cmds) => {
for cmd in cmds {
let print = LKEval::new(Editor::new(), cmd, STATE.clone(), password).eval();
print.out.copy(&out);
}
}
Err(e) => out.e(format!("error: {}", e)),
}
out.output().join("\n")
}
+3 -5
View File
@@ -4,13 +4,11 @@ extern crate hel;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn ok_add(a: i32, b: i32) -> i32 {
a + b + 1
}
mod hel_state;
/// Call once at page load: routes Rust panics to the browser console with a
/// readable message + stack instead of an opaque "unreachable" trap.
#[wasm_bindgen]
pub fn hel_init() {
console_error_panic_hook::set_once();
}
+347
View File
@@ -0,0 +1,347 @@
/* LesS/KEY web app. Visual language mirrored from kaizenkodo.org
(~/Repos/Identity/website/main.css): washi-paper cream, ocean blue, hanko red,
Poppins / Inter / JetBrains Mono. Light, responsive, restrained. */
:root {
--cream: #f4f0e8;
--paper: #fbf8f2;
--ink: #15181b;
--muted: #6a675e;
--line: #ddd6c6;
--ocean: #1b4161;
--ocean-2: #2f6286;
--deep: #0a2540;
--seal: #e23b2e;
--disp: "Poppins", system-ui, sans-serif;
--body: "Inter", system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
--maxw: 1320px;
--gutter: clamp(16px, 3.5vw, 48px);
--radius: 16px;
}
/* Self-hosted fonts, bundled with the app so it makes NO external requests and
talks to no third party (no Google Fonts, no CDN). Latin subset only. */
@font-face { font-family:"Poppins"; font-style:normal; font-weight:600; font-display:swap; src:url("./assets/fonts/Poppins-600.woff2") format("woff2"); }
@font-face { font-family:"Poppins"; font-style:normal; font-weight:700; font-display:swap; src:url("./assets/fonts/Poppins-700.woff2") format("woff2"); }
@font-face { font-family:"Inter"; font-style:normal; font-weight:400; font-display:swap; src:url("./assets/fonts/Inter-400.woff2") format("woff2"); }
@font-face { font-family:"Inter"; font-style:normal; font-weight:500; font-display:swap; src:url("./assets/fonts/Inter-500.woff2") format("woff2"); }
@font-face { font-family:"Inter"; font-style:normal; font-weight:600; font-display:swap; src:url("./assets/fonts/Inter-600.woff2") format("woff2"); }
@font-face { font-family:"JetBrains Mono"; font-style:normal; font-weight:400; font-display:swap; src:url("./assets/fonts/JetBrainsMono-400.woff2") format("woff2"); }
@font-face { font-family:"JetBrains Mono"; font-style:normal; font-weight:500; font-display:swap; src:url("./assets/fonts/JetBrainsMono-500.woff2") format("woff2"); }
/* Masked-secret font: dots glyphs so the real chars stay selectable/copyable.
WebKit/Blink use -webkit-text-security; Firefox falls back to this font. */
@font-face { font-family:"text-security-disc"; font-display:swap; src:url("./assets/fonts/text-security-disc.woff2") format("woff2"); }
* { margin: 0; padding: 0; box-sizing: border-box; }
html { height: 100%; overflow-x: hidden; overscroll-behavior-x: none; }
body {
min-height: 100%;
overflow-x: hidden; /* no sideways pan; page is single column */
overscroll-behavior-x: none; /* kill horizontal rubber-band / swipe-nav */
font-family: var(--body);
font-size: 16px;
line-height: 1.6;
color: var(--ink);
background-color: var(--cream);
background-image:
linear-gradient(rgba(27, 65, 97, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(27, 65, 97, 0.05) 1px, transparent 1px);
background-size: 48px 48px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.wrap { width: 100%; max-width: var(--maxw); margin: 0 auto; padding-inline: var(--gutter); }
/* Header */
.site {
position: sticky; top: 0; z-index: 50;
background: rgba(244, 240, 232, 0.82);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.nav { display: flex; align-items: center; gap: 14px; height: 68px; }
.brand { display: inline-flex; align-items: center; gap: 11px; text-decoration: none; }
.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; }
/* Always-visible link back to the main Kaizenkodo site. */
.backlink { display: inline-flex; align-items: center; gap: 5px; flex: none; font-family: var(--mono); font-size: 12px; letter-spacing: 0.03em; color: var(--muted); text-decoration: none; white-space: nowrap; padding: 6px 11px; border-radius: 999px; border: 1px solid var(--line); }
.backlink:hover { color: var(--ocean); border-color: var(--ocean); background: rgba(27, 65, 97, 0.05); }
.backlink .arr { font-size: 14px; line-height: 1; }
/* Installed as a home-screen app (standalone launch): hide the back-to-site link.
With it gone there's room, so show the wordmark again even on narrow phones
(higher specificity than the <=540px hide, so it wins regardless of order). */
@media (display-mode: standalone) { .backlink { display: none; } }
html.standalone .backlink { display: none; }
html.standalone .wordmark { display: inline; }
/* 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: 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);
}
/* minmax(0,1fr) lets the cards shrink below their content's intrinsic width, so
the console's pre/no-wrap lines scroll inside it instead of widening the page. */
main { padding: clamp(20px, 3vw, 34px) 0 56px; display: grid; grid-template-columns: minmax(0, 1fr); 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; }
.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; }
/* Privacy callout; make the "no server" promise visually prominent. */
.about-privacy {
margin: 6px 0 2px; padding: 13px 16px; border-radius: 12px;
border: 1px solid var(--line); border-left: 3px solid var(--ocean);
background: rgba(27, 65, 97, 0.045); color: var(--ink); font-size: 14.5px;
}
.about-privacy strong { color: var(--ocean); }
@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: clamp(16px, 2.6vw, 28px);
}
.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; }
.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 */
.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"] {
/* 16px min: iOS Safari auto-zooms (and stays zoomed) when a focused input is
under 16px. Keep all focusable fields >= 16px to stop that. */
font-family: var(--mono); font-size: 16px; color: var(--ink);
background: #fff; border: 1px solid var(--line); border-radius: 10px;
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); }
/* Masked TEXT inputs (master, pass overlay): visually masked but type=text, so
the browser never treats them as password fields, so no autofill / save / "suggest
strong password". Firefox uses the bundled disc font. */
input.mask { -webkit-text-security: disc; }
@supports not (-webkit-text-security: disc) { input.mask { font-family: "text-security-disc", var(--mono); } }
/* Click the masked master field to reveal what you typed; click again to re-mask. */
#master { cursor: pointer; }
input.mask.revealed { -webkit-text-security: none; }
@supports not (-webkit-text-security: disc) { input.mask.revealed { font-family: var(--mono); } }
/* Buttons */
.btn {
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, border-color .15s;
}
.btn:hover { transform: translateY(-1px); background: var(--deep); border-color: var(--deep); }
.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; }
/* Confirmed states ("Correct ✓" / "Stored ✓"): same filled-ocean look for both. */
.btn.small.is-correct,
.btn.small.is-stored { background: var(--ocean); color: var(--cream); border-color: var(--ocean); }
.btn.small.is-correct:hover { background: var(--deep); border-color: var(--deep); }
.btn:disabled { opacity: .4; cursor: not-allowed; pointer-events: none; }
/* is-stored is disabled (re-storing is a no-op); keep it fully opaque, not dimmed. */
.btn.small.is-stored { opacity: 1; }
/* 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;
}
.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; flex-wrap: wrap; }
.result .len { font-family: var(--mono); font-size: 11px; color: var(--muted); }
/* Quick-gen result: dynamic font (scales down on narrow phones) and wraps so a
long password stays inside the card. min-width:0 lets it shrink/wrap within
the flex row. Scoped so the console table keeps its fixed-width alignment. */
.result .secret { font-size: clamp(13px, 4vw, 19px); white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; }
/* 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, so 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;
}
/* Reveal flips masking only; colour is reserved for correctness (below). */
.secret.revealed { -webkit-text-security: none; }
/* Quick-gen result: ocean = this master is marked correct; otherwise ink ("black"). */
.result .secret.correct { color: var(--ocean); font-weight: 500; }
/* Console keeps its old cue: a revealed secret turns ocean. */
.console-out .secret.revealed { 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: 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; 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 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: 9px 0; background: transparent; }
.console-in input:focus { box-shadow: none; }
.toast {
position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%);
background: var(--deep); color: var(--cream); font-family: var(--mono); font-size: 13px;
padding: 10px 18px; border-radius: 999px; box-shadow: 0 8px 24px rgba(10, 16, 24, 0.35);
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 80;
}
.toast.show { opacity: 1; }
/* Modal (import/export) */
.modal-bg {
position: fixed; inset: 0; background: rgba(10, 16, 24, 0.45);
display: none; align-items: center; justify-content: center; padding: 20px; z-index: 90;
}
.modal-bg.show { display: flex; }
.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: 260px; resize: vertical; font-family: var(--mono); font-size: 16px;
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; }
/* padding-block only: keep .wrap's inline gutter so the footer lines up with the
body cards instead of running to the window edge. */
.foot { color: var(--muted); font-family: var(--mono); font-size: 12px; text-align: center; padding-block: 8px 40px; }
.foot a { color: var(--ocean); text-decoration: none; border-bottom: 1px solid rgba(27, 65, 97, 0.3); }
.foot a:hover { border-bottom-color: var(--ocean); }
/* Version badge: a quiet mono pill under the footer prose (mirrors .backlink).
Decent and out of the way; click opens the changelog overlay. */
.ver {
display: inline-block; margin-top: 10px;
font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em;
color: var(--muted); background: transparent;
border: 1px solid var(--line); border-radius: 999px; padding: 3px 11px;
cursor: pointer; transition: color .15s, border-color .15s, background .15s;
}
.ver:hover { color: var(--ocean); border-color: var(--ocean); background: rgba(27, 65, 97, 0.05); }
.ver:focus-visible { outline: none; border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97, 0.12); }
/* Changelog overlay body (inside the shared .modal). Scrolls if it ever grows. */
.changelog { max-height: 60vh; overflow: auto; margin-top: 4px; }
.cl-entry { padding: 16px 0; border-top: 1px solid var(--line); }
.cl-entry:first-child { border-top: none; padding-top: 2px; }
.cl-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 3px; }
.cl-ver { font-family: var(--disp); font-weight: 700; font-size: 16px; color: var(--ocean); letter-spacing: -0.01em; }
.cl-date { font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; color: var(--muted); }
/* "Latest" flag, pushed to the right edge of the newest release's head. */
.cl-latest {
margin-left: auto; flex: none; align-self: center;
font-family: var(--mono); font-size: 9.5px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.1em;
color: #fff; background: var(--seal); border-radius: 999px; padding: 2px 9px;
}
/* Release theme line under the version. */
.cl-title { font-family: var(--disp); font-weight: 600; font-size: 14px; color: var(--ink); margin: 0 0 12px; }
.changelog ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 9px; }
.changelog li { display: flex; align-items: baseline; gap: 12px; color: var(--ink); font-size: 14px; line-height: 1.5; }
/* Category tag in a fixed-width column, so the change text shares one clean left edge. */
.cl-tag {
flex: none; width: 68px; text-align: center; box-sizing: border-box;
font-family: var(--mono); font-size: 9.5px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.08em;
padding: 3px 0; border-radius: 6px; transform: translateY(-1px);
}
.cl-tag-new { color: var(--ocean); background: rgba(27, 65, 97, 0.10); }
.cl-tag-improved { color: var(--muted); background: rgba(106, 103, 94, 0.13); }
.cl-text { min-width: 0; }
.changelog code { font-family: var(--mono); font-size: 12px; color: var(--ocean-2); background: rgba(27,65,97,.06); padding: 1px 5px; border-radius: 5px; }
/* On phones the fixed tag column would crowd the text — let each item stack. */
@media (max-width: 540px) {
.changelog li { flex-wrap: wrap; gap: 5px 10px; }
.cl-tag { width: auto; padding: 3px 9px; }
}
@media (max-width: 680px) {
.fields { grid-template-columns: 1fr; align-items: stretch; }
.nav { gap: 9px; height: 60px; }
.tagline { display: none; }
body { font-size: 15px; }
/* free space for the back link: the ensō icon alone carries the brand on phones */
.backlink { padding: 5px 9px; }
/* result row stacks cleanly on phones: the password on its own full-width line,
then a tidy meta line (result + len ........ Copy / Store). */
.result { align-items: center; row-gap: 12px; }
.result .secret { order: 1; flex: 1 1 100%; font-size: clamp(15px, 5.2vw, 19px); }
.result .tag { order: 2; }
.result .len { order: 3; margin-right: auto; }
.result .actions { order: 4; margin-left: 0; }
}
/* On narrow phones drop the wordmark so the back link + ensō + buttons all fit. */
@media (max-width: 540px) { .wordmark { display: none; } }