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).
This commit is contained in:
Oleksandr Kozachuk
2026-06-09 22:45:44 +02:00
parent e25746d5f0
commit 10a21d1f9d
6 changed files with 130 additions and 41 deletions
+34 -10
View File
@@ -38,17 +38,20 @@ peg::parser! {
rule mname() -> Password = &(word() _ word() _ num()? mode() _ date()) pr:word() _ pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()? 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) } { Password::new(Some(pr), pn, pl, pm, 99, pd, pc) }
// prefix + name + [len]mode (no seq/date) -> defaults seq 99, date now // 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() 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(), None) } { 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()? 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) } { Password::new(None, pn, pl, pm, 99, pd, pc) }
rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() pc:comment()?
{ Password::new(None, pn, pl, pm, 99, Date::now(), None) } { Password::new(None, pn, pl, pm, 99, Date::now(), pc) }
// prefix + name only -> defaults mode R, seq 99, date now // prefix + name (+ optional comment) -> defaults mode R, seq 99, date now.
rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:word() // A `^parent` token is never a name, so a `^`-leading second word fails here and
{ Password::new(Some(pr), pn, None, Mode::Regular, 99, Date::now(), None) } // falls through to qname, which keeps it as the comment (the parent marker).
rule qname() -> Password = &(word()) pn:word() rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:$(!"^" ['!'..='~']+) pc:comment()?
{ Password::new(None, pn, None, Mode::Regular, 99, Date::now(), None) } { 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())? {? 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") } match name { Some(n) => Ok(n), None => Err("failed to parse password description") }
} }
@@ -107,7 +110,7 @@ peg::parser! {
rule pass_cmd() -> Command<'input> = p:(pass_long_cmd() / pass_short_cmd()) { p } 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 correct_cmd() -> Command<'input> = "correct" _ name:word() { Command::Correct(name) }
rule uncorrect_cmd() -> Command<'input> = "uncorrect" _ name:word() { Command::Uncorrect(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 enc_cmd() -> Command<'input> = "enc" _ name:word() { Command::Enc(name) }
rule rm_cmd() -> Command<'input> = "rm" _ name:word() { Command::Rm(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) } rule comment_cmd() -> Command<'input> = "comment" _ name:word() c:comment()? { Command::Comment(name, c) }
@@ -245,6 +248,27 @@ 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] #[test]
fn parse_password_test() { fn parse_password_test() {
assert_eq!( assert_eq!(
+6 -2
View File
@@ -133,10 +133,14 @@ impl<'a> LKEval<'a> {
Command::Set(key, value) => { to_history = false; self.cmd_set(&out, key, value); } 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, None) => self.cmd_pass(&out, &name, &None),
Command::Pass(name, pass) => { to_history = false; self.cmd_pass(&out, &name, &pass); }, 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)), Some(_) => out.o(format!("Removed saved password for {}", name)),
None => out.e(format!("error: saved password for {} not found", 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::Correct(name) => self.cmd_correct(&out, name, true, None),
Command::Uncorrect(name) => self.cmd_correct(&out, name, false, None), Command::Uncorrect(name) => self.cmd_correct(&out, name, false, None),
Command::Noop => { to_history = false; }, Command::Noop => { to_history = false; },
@@ -156,7 +160,7 @@ impl<'a> LKEval<'a> {
" enc <name> show the generated password\n", " enc <name> show the generated password\n",
" gen[N] <name> N numbered variants; name ends in G.. (all) or X.. (random)\n", " gen[N] <name> N numbered variants; name ends in G.. (all) or X.. (random)\n",
" pass <name> [pw] cache a master / override for an entry's subtree\n", " pass <name> [pw] cache a master / override for an entry's subtree\n",
" unpass <name> forget a cached password (unpass / = the root master)\n", " unpass [name] forget a cached password (unpass / = root; unpass = all)\n",
" correct <name> trust this password's hash uncorrect <name> untrust it\n", " correct <name> trust this password's hash uncorrect <name> untrust it\n",
"\n", "\n",
"catalog\n", "catalog\n",
+3 -2
View File
@@ -97,7 +97,7 @@ pub enum Command<'a> {
Enc(Name), Enc(Name),
Gen(u32, PasswordRef), Gen(u32, PasswordRef),
Pass(Name, Option<String>), Pass(Name, Option<String>),
UnPass(Name), UnPass(Option<Name>),
Correct(Name), Correct(Name),
Uncorrect(Name), Uncorrect(Name),
PasteBuffer(String), 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::Gen(a, b) => write!(f, "gen{} {}", a, b.lock().borrow().to_string().trim()),
Command::Pass(a, None) => write!(f, "pass {}", a), Command::Pass(a, None) => write!(f, "pass {}", a),
Command::Pass(a, Some(b)) => write!(f, "pass {} {}", a, b), 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::Correct(s) => write!(f, "correct {}", s),
Command::Uncorrect(s) => write!(f, "uncorrect {}", s), Command::Uncorrect(s) => write!(f, "uncorrect {}", s),
Command::PasteBuffer(s) => write!(f, "pb {}", s), Command::PasteBuffer(s) => write!(f, "pb {}", s),
+72 -25
View File
@@ -66,8 +66,10 @@
<h4>Quick password</h4> <h4>Quick password</h4>
<p> <p>
Type an account name and your master phrase. The password appears instantly, Type an account name and your master phrase. The password appears instantly,
masked. Click it to reveal, or press <strong>Copy</strong>. <strong>Store</strong> masked; click it (or the master) to reveal. Press <strong>Copy</strong>, or
remembers the <em>name</em> (never the password) in this browser. <strong>Store</strong> to remember the <em>name</em> (never the password).
Click <strong>Mark correct</strong> once and the right master shows in colour,
so a typo stays black.
</p> </p>
</div> </div>
<div> <div>
@@ -85,6 +87,8 @@
After a name you can add a length and a mode. <code>R</code> is six memorable words 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; (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>. <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> </p>
</div> </div>
</div> </div>
@@ -100,10 +104,11 @@
<div class="eyebrow">Generate</div> <div class="eyebrow">Generate</div>
<h2>Quick password</h2> <h2>Quick password</h2>
<p class="hint"> <p class="hint">
The same name and master always make the same password, generated live in The same name and master always make the same password, computed live in your
your browser. Nothing is saved unless you press <strong>Store</strong>, and browser. Your master is kept in memory for this tab only, never written to
then only in this browser on your device, never on a server. The app never storage, and the app never talks to a server. <strong>Store</strong> saves
communicates with a server at all. just the entry name; <strong>Mark correct</strong> saves only a one-way hash,
both on this device.
</p> </p>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
@@ -112,7 +117,7 @@
</div> </div>
<div class="field"> <div class="field">
<label for="master">Master password</label> <label for="master">Master password</label>
<input type="text" id="master" class="mask" placeholder="your master phrase" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" data-1p-ignore data-lpignore="true" data-bwignore="true" /> <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> </div>
<div class="result"> <div class="result">
@@ -120,6 +125,7 @@
<span class="secret" id="qsecret" title="click to reveal"></span> <span class="secret" id="qsecret" title="click to reveal"></span>
<span class="len" id="qlen"></span> <span class="len" id="qlen"></span>
<span class="actions"> <span class="actions">
<button class="btn ghost small" id="markCorrect" title="Remember this master as correct on this device, so the right master shows in colour." disabled>Mark correct</button>
<button class="btn small" id="copy">Copy</button> <button class="btn small" id="copy">Copy</button>
<button class="btn ghost small" id="store">Store</button> <button class="btn ghost small" id="store">Store</button>
</span> </span>
@@ -202,7 +208,13 @@
const CATALOG_KEY = "hel_catalog"; const CATALOG_KEY = "hel_catalog";
// ---- host imports (wasm calls these by bare name → must be globals) ---- // ---- host imports (wasm calls these by bare name → must be globals) ----
window.hel_get_password = () => (masterEl() ? masterEl().value : "") || ""; // 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) => { window.hel_rnd_range = (s, e) => {
if (e <= s) return s; if (e <= s) return s;
const r = crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296; const r = crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296;
@@ -243,45 +255,77 @@
return s; return s;
} }
// ---- quick generate (live; stateless unless Store) ---- // ---- 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 lastSecret = "";
let appliedMaster = null; // last master value pushed to the engine (debounce hint)
function refreshCorrectBtn(enabled, isCorrect) {
const b = $("#markCorrect");
if (!b) return;
b.disabled = !enabled;
b.classList.toggle("is-correct", !!enabled && !!isCorrect);
b.textContent = enabled && isCorrect ? "Correct ✓" : "Mark correct";
b.title = enabled && isCorrect
? "This master is remembered as correct on this device. Click to forget it."
: "Remember this master as correct on this device, so the right master shows in colour.";
}
function quickGen(silent) { function quickGen(silent) {
const spec = $("#qname").value.trim(); const spec = $("#qname").value.trim();
const master = masterEl().value;
const sec = $("#qsecret"); const sec = $("#qsecret");
const lenEl = $("#qlen"); const lenEl = $("#qlen");
sec.classList.remove("revealed"); sec.classList.remove("revealed");
if (!spec || !master) {
sec.textContent = "";
lenEl.textContent = "";
lastSecret = "";
return;
}
// The entry name is NOT always the first token; a leading prefix // The entry name is NOT always the first token; a leading prefix
// (like `*P0 test1 …`) means the name is the next word. Parse it. // (like `*P0 test1 …`) means the name is the next word. Parse it.
const name = hel_parse_name(spec); const name = spec ? hel_parse_name(spec) : "";
if (!name) { if (!name) {
sec.textContent = ""; sec.textContent = ""; sec.classList.remove("correct");
lenEl.textContent = ""; lenEl.textContent = ""; lastSecret = "";
lastSecret = ""; refreshCorrectBtn(false, false);
return; return;
} }
// hel caches the root master in secrets["/"]; clear it so the live result const master = masterEl().value;
// always reflects the current master field (not a stale cached value). // A new master in the field is authoritative: forget the cached chain so every
hel_command("unpass /"); // 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); let out = hel_command("enc " + name);
if (/not found/.test(out)) { if (/^error: name .* not found/m.test(out)) { // unstored leaf: ephemeral add/enc/rm
hel_command("add " + spec); hel_command("add " + spec);
out = hel_command("enc " + name); out = hel_command("enc " + name);
hel_command("rm " + name); hel_command("rm " + name);
} }
hel_command("unpass /");
const pw = stripNoise(out).pop() || ""; const pw = stripNoise(out).pop() || "";
// Colour = "is the master correct?" — derived from the engine so it reflects
// `correct /` / `unpass /` typed in the console too. `enc /` re-emits the root
// correctness check; needs secrets["/"], which the gen above just set.
const rootChk = hel_command("enc /");
const haveRoot = !/^error:/m.test(rootChk);
const masterOK = haveRoot && !/warning: password \/ is not marked as correct/.test(rootChk);
lastSecret = pw; lastSecret = pw;
sec.textContent = pw; sec.textContent = pw;
sec.classList.toggle("correct", !!pw && masterOK);
lenEl.textContent = pw ? "len " + pw.length : ""; lenEl.textContent = pw ? "len " + pw.length : "";
refreshCorrectBtn(!!pw && haveRoot, masterOK);
if (!pw && !silent) toast("No output"); if (!pw && !silent) toast("No output");
} }
function toggleCorrect() {
const b = $("#markCorrect");
if (!b || b.disabled) return;
const wasCorrect = b.classList.contains("is-correct");
// Store / remove the root-master hash on the device (same as the console).
hel_command(wasCorrect ? "uncorrect /" : "correct /");
quickGen(true);
toast(wasCorrect ? "Master no longer marked correct" : "Master marked correct");
}
function quickStore() { function quickStore() {
const spec = $("#qname").value.trim(); const spec = $("#qname").value.trim();
if (!spec) return toast("Enter a name"); if (!spec) return toast("Enter a name");
@@ -413,8 +457,11 @@
} }
}); });
$("#qsecret").addEventListener("click", () => $("#qsecret").classList.toggle("revealed")); $("#qsecret").addEventListener("click", () => $("#qsecret").classList.toggle("revealed"));
// Click the master field to show what you typed; click again to re-mask.
$("#master").addEventListener("click", () => $("#master").classList.toggle("revealed"));
$("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy")); $("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy"));
$("#store").onclick = quickStore; $("#store").onclick = quickStore;
$("#markCorrect").onclick = toggleCorrect;
// pass overlay wiring // pass overlay wiring
$("#passOk").onclick = submitPass; $("#passOk").onclick = submitPass;
Binary file not shown.
+15 -2
View File
@@ -163,6 +163,10 @@ input:focus { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97,
strong password". Firefox uses the bundled disc font. */ strong password". Firefox uses the bundled disc font. */
input.mask { -webkit-text-security: disc; } input.mask { -webkit-text-security: disc; }
@supports not (-webkit-text-security: disc) { input.mask { font-family: "text-security-disc", var(--mono); } } @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 */ /* Buttons */
.btn { .btn {
@@ -177,6 +181,10 @@ input.mask { -webkit-text-security: disc; }
.btn.ghost { background: transparent; color: var(--ocean); } .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.ghost:hover { background: rgba(27, 65, 97, 0.06); color: var(--ocean); border-color: var(--ocean); }
.btn.small { padding: 7px 15px; font-size: 13px; } .btn.small { padding: 7px 15px; font-size: 13px; }
/* "Mark correct" toggle: filled ocean once the master is remembered-correct. */
.btn.small.is-correct { 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; }
/* Quick-gen result row */ /* Quick-gen result row */
.result { .result {
@@ -185,7 +193,7 @@ input.mask { -webkit-text-security: disc; }
border-radius: 12px; background: #fff; min-height: 58px; 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 .tag { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.result .actions { margin-left: auto; display: flex; gap: 8px; } .result .actions { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
.result .len { font-family: var(--mono); font-size: 11px; color: var(--muted); } .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 /* 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 long password stays inside the card. min-width:0 lets it shrink/wrap within
@@ -202,7 +210,12 @@ input.mask { -webkit-text-security: disc; }
font-size: 19px; color: var(--ink); font-size: 19px; color: var(--ink);
cursor: pointer; user-select: text; white-space: pre; cursor: pointer; user-select: text; white-space: pre;
} }
.secret.revealed { -webkit-text-security: none; color: var(--ocean); } /* 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 /* 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). */ (single-line result only; the console table is a WebKit/Blink concern). */
@supports not (-webkit-text-security: disc) { @supports not (-webkit-text-security: disc) {