mevebu 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # mevebu
4
4
 
5
- **205 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
5
+ **215 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
6
6
 
7
7
  A private toolbox for the small jobs that do not deserve a whole app: format JSON, decode a JWT, make a password, build a cron line, pick a color, scan a QR code, and a lot more.
8
8
 
@@ -10,7 +10,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
10
10
  [![npm downloads](https://img.shields.io/npm/dm/mevebu?color=2ea44f)](https://www.npmjs.com/package/mevebu)
11
11
  [![License: MIT](https://img.shields.io/github/license/mevebu/mevebu?color=blue)](./LICENSE)
12
12
  [![GitHub stars](https://img.shields.io/github/stars/mevebu/mevebu?style=flat&color=yellow)](https://github.com/mevebu/mevebu/stargazers)
13
- ![Tools](https://img.shields.io/badge/tools-205-brightgreen)
13
+ ![Tools](https://img.shields.io/badge/tools-215-brightgreen)
14
14
  ![Privacy](https://img.shields.io/badge/privacy-100%25%20in--browser-blue)
15
15
 
16
16
  [Quick start](#quick-start) · [What's inside](#whats-inside) · [Privacy](#privacy-by-design) · [Credit](#credit) · [Development](#development) · [FAQ](#faq)
@@ -23,7 +23,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
23
23
 
24
24
  ## What is mevebu?
25
25
 
26
- mevebu is a collection of **205 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
26
+ mevebu is a collection of **215 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
27
27
 
28
28
  Think of it as a clean, private alternative to the usual "free online tools" websites. There are no accounts, no ads in your way, no uploads, and no tracking. Your text, passwords, tokens, and files stay on your device.
29
29
 
@@ -64,12 +64,12 @@ mevebu tries port `5555` first, then `5678`, then `5432`, and finally any free p
64
64
 
65
65
  ## What's inside
66
66
 
67
- mevebu has **205 tools in 20 categories**. Here is the full breakdown:
67
+ mevebu has **215 tools in 20 categories**. Here is the full breakdown:
68
68
 
69
69
  | Category | Tools |
70
70
  | ------------------ | ----: |
71
71
  | Data Generator | 10 |
72
- | Passwords and keys | 10 |
72
+ | Passwords and keys | 20 |
73
73
  | Developer | 21 |
74
74
  | Convert | 15 |
75
75
  | Text and data | 14 |
@@ -88,7 +88,7 @@ mevebu has **205 tools in 20 categories**. Here is the full breakdown:
88
88
  | GitHub | 8 |
89
89
  | Units | 8 |
90
90
  | QR codes | 2 |
91
- | **Total** | **205** |
91
+ | **Total** | **215** |
92
92
 
93
93
  The counts come straight from the tool registry, so this table and the app never drift apart.
94
94
 
@@ -176,7 +176,7 @@ That is all. The catalog, search, breadcrumbs, and category pages pick it up aut
176
176
  ## FAQ
177
177
 
178
178
  **What is mevebu?**
179
- mevebu is a free, open source collection of 205 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
179
+ mevebu is a free, open source collection of 215 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
180
180
 
181
181
  **Is mevebu really free?**
182
182
  Yes. It is MIT licensed, with no paid tier, no account, and no usage limits.
@@ -500,6 +500,9 @@
500
500
  width: var(--ctl);
501
501
  height: var(--ctl);
502
502
  }
503
+ .h-1\.5 {
504
+ height: calc(var(--spacing) * 1.5);
505
+ }
503
506
  .h-2 {
504
507
  height: calc(var(--spacing) * 2);
505
508
  }
@@ -566,12 +569,21 @@
566
569
  .min-h-8 {
567
570
  min-height: calc(var(--spacing) * 8);
568
571
  }
572
+ .min-h-9 {
573
+ min-height: calc(var(--spacing) * 9);
574
+ }
569
575
  .min-h-11 {
570
576
  min-height: calc(var(--spacing) * 11);
571
577
  }
572
578
  .min-h-24 {
573
579
  min-height: calc(var(--spacing) * 24);
574
580
  }
581
+ .min-h-28 {
582
+ min-height: calc(var(--spacing) * 28);
583
+ }
584
+ .min-h-32 {
585
+ min-height: calc(var(--spacing) * 32);
586
+ }
575
587
  .min-h-40 {
576
588
  min-height: calc(var(--spacing) * 40);
577
589
  }
@@ -984,9 +996,21 @@
984
996
  --tw-border-style: dashed;
985
997
  border-style: dashed;
986
998
  }
999
+ .border-current\/15 {
1000
+ border-color: currentcolor;
1001
+ @supports (color: color-mix(in lab, red, red)) {
1002
+ border-color: color-mix(in oklab, currentcolor 15%, transparent);
1003
+ }
1004
+ }
987
1005
  .border-danger {
988
1006
  border-color: var(--danger);
989
1007
  }
1008
+ .border-danger\/40 {
1009
+ border-color: var(--danger);
1010
+ @supports (color: color-mix(in lab, red, red)) {
1011
+ border-color: color-mix(in oklab, var(--danger) 40%, transparent);
1012
+ }
1013
+ }
990
1014
  .border-jade {
991
1015
  border-color: var(--jade);
992
1016
  }
@@ -1017,6 +1041,18 @@
1017
1041
  .bg-danger {
1018
1042
  background-color: var(--danger);
1019
1043
  }
1044
+ .bg-danger\/10 {
1045
+ background-color: var(--danger);
1046
+ @supports (color: color-mix(in lab, red, red)) {
1047
+ background-color: color-mix(in oklab, var(--danger) 10%, transparent);
1048
+ }
1049
+ }
1050
+ .bg-danger\/15 {
1051
+ background-color: var(--danger);
1052
+ @supports (color: color-mix(in lab, red, red)) {
1053
+ background-color: color-mix(in oklab, var(--danger) 15%, transparent);
1054
+ }
1055
+ }
1020
1056
  .bg-deck {
1021
1057
  background-color: var(--deck);
1022
1058
  }
@@ -1367,9 +1403,19 @@
1367
1403
  .text-\[0\.92em\] {
1368
1404
  font-size: 0.92em;
1369
1405
  }
1406
+ .text-\[clamp\(20px\,3\.4vw\,28px\)\] {
1407
+ font-size: clamp(20px, 3.4vw, 28px);
1408
+ }
1370
1409
  .text-\[clamp\(34px\,6vw\,52px\)\] {
1371
1410
  font-size: clamp(34px, 6vw, 52px);
1372
1411
  }
1412
+ .text-\[clamp\(38px\,9vw\,62px\)\] {
1413
+ font-size: clamp(38px, 9vw, 62px);
1414
+ }
1415
+ .leading-\[1\.4\] {
1416
+ --tw-leading: 1.4;
1417
+ line-height: 1.4;
1418
+ }
1373
1419
  .leading-\[1\.5\] {
1374
1420
  --tw-leading: 1.5;
1375
1421
  line-height: 1.5;
@@ -1390,6 +1436,10 @@
1390
1436
  --tw-leading: 1.65;
1391
1437
  line-height: 1.65;
1392
1438
  }
1439
+ .leading-none {
1440
+ --tw-leading: 1;
1441
+ line-height: 1;
1442
+ }
1393
1443
  .leading-normal {
1394
1444
  --tw-leading: var(--leading-normal);
1395
1445
  line-height: var(--leading-normal);
@@ -1426,6 +1476,10 @@
1426
1476
  --tw-tracking: -0.035em;
1427
1477
  letter-spacing: -0.035em;
1428
1478
  }
1479
+ .tracking-\[0\.2em\] {
1480
+ --tw-tracking: 0.2em;
1481
+ letter-spacing: 0.2em;
1482
+ }
1429
1483
  .tracking-\[0\.06em\] {
1430
1484
  --tw-tracking: 0.06em;
1431
1485
  letter-spacing: 0.06em;
@@ -1434,6 +1488,10 @@
1434
1488
  --tw-tracking: 0.08em;
1435
1489
  letter-spacing: 0.08em;
1436
1490
  }
1491
+ .tracking-\[0\.14em\] {
1492
+ --tw-tracking: 0.14em;
1493
+ letter-spacing: 0.14em;
1494
+ }
1437
1495
  .text-balance {
1438
1496
  text-wrap: balance;
1439
1497
  }
@@ -1482,6 +1540,9 @@
1482
1540
  .text-void {
1483
1541
  color: var(--void);
1484
1542
  }
1543
+ .capitalize {
1544
+ text-transform: capitalize;
1545
+ }
1485
1546
  .lowercase {
1486
1547
  text-transform: lowercase;
1487
1548
  }
@@ -1632,10 +1693,18 @@
1632
1693
  --tw-duration: 200ms;
1633
1694
  transition-duration: 200ms;
1634
1695
  }
1696
+ .duration-1000 {
1697
+ --tw-duration: 1000ms;
1698
+ transition-duration: 1000ms;
1699
+ }
1635
1700
  .ease-\[var\(--ease\)\] {
1636
1701
  --tw-ease: var(--ease);
1637
1702
  transition-timing-function: var(--ease);
1638
1703
  }
1704
+ .ease-linear {
1705
+ --tw-ease: linear;
1706
+ transition-timing-function: linear;
1707
+ }
1639
1708
  .outline-none {
1640
1709
  --tw-outline-style: none;
1641
1710
  outline-style: none;
@@ -750,7 +750,7 @@ export function blank(iconName, text) {
750
750
  // ---------- version ----------
751
751
 
752
752
  // Kept in one place. The CLI reads the same value from package.json.
753
- export const VERSION = "2.1.0";
753
+ export const VERSION = "2.3.0";
754
754
  export const PACKAGE_NAME = "mevebu";
755
755
  export const REPO_SLUG = "mevebu/mevebu";
756
756
 
@@ -391,6 +391,186 @@ export const TOOLS = [
391
391
  keywords: "url inspect phishing host homograph link safe",
392
392
  load: () => import("./tools/url-inspector.js"),
393
393
  },
394
+ {
395
+ id: "totp-generator",
396
+ category: "security",
397
+ name: "TOTP Generator",
398
+ icon: "shield-check",
399
+ note: "Read the code your authenticator would show.",
400
+ about: "Paste a Base32 secret or an otpauth URI. The code refreshes on its own.",
401
+ tutorial: {
402
+ intro: "Get a time based one time password from a secret.",
403
+ steps: [
404
+ "Paste a Base32 secret, or a full otpauth:// URI.",
405
+ "Pick the digits, the period and the hash if they are not the default.",
406
+ "Copy the code. It changes on its own every period.",
407
+ ],
408
+ },
409
+ keywords: "totp otp 2fa authenticator google authenticator base32 code",
410
+ load: () => import("./tools/totp-generator.js"),
411
+ },
412
+ {
413
+ id: "totp-verifier",
414
+ category: "security",
415
+ name: "TOTP Verifier",
416
+ icon: "shield-check",
417
+ note: "Check a code against a secret.",
418
+ about: "Shows which step matched, so a wrong secret is distinguishable from a wrong clock.",
419
+ tutorial: {
420
+ intro: "Debug a two factor integration.",
421
+ steps: [
422
+ "Paste the secret and the code.",
423
+ "Set the drift window, or leave it at one step either side.",
424
+ "Read the verdict, which names the step that matched.",
425
+ ],
426
+ },
427
+ keywords: "totp verify validate otp 2fa drift clock debug",
428
+ load: () => import("./tools/totp-verifier.js"),
429
+ },
430
+ {
431
+ id: "passphrase-generator",
432
+ category: "security",
433
+ name: "Passphrase Generator",
434
+ icon: "key-round",
435
+ note: "Make a passphrase you can type from memory.",
436
+ about: "Words from a bundled list, with the entropy in bits shown next to it.",
437
+ tutorial: {
438
+ intro: "Make a password that is long and memorable.",
439
+ steps: [
440
+ "Pick how many words.",
441
+ "Choose a separator, and whether to add a number or a symbol.",
442
+ "Press Generate, then copy it.",
443
+ ],
444
+ },
445
+ keywords: "passphrase password memorable words diceware entropy",
446
+ load: () => import("./tools/passphrase-generator.js"),
447
+ },
448
+ {
449
+ id: "file-hash",
450
+ category: "security",
451
+ name: "File Hash Checker",
452
+ icon: "file-key",
453
+ note: "Hash a file and compare the checksum.",
454
+ about: "SHA-1, SHA-256 and SHA-512, read in the tab, with a match or mismatch.",
455
+ tutorial: {
456
+ intro: "Verify a download before you open it.",
457
+ steps: [
458
+ "Drop the file.",
459
+ "Paste the checksum you were given.",
460
+ "Read match or mismatch, and copy any digest you need.",
461
+ ],
462
+ },
463
+ keywords: "file hash checksum sha256 verify download integrity",
464
+ load: () => import("./tools/file-hash.js"),
465
+ },
466
+ {
467
+ id: "aes-text",
468
+ category: "security",
469
+ name: "AES Text Encrypt",
470
+ icon: "lock",
471
+ note: "Encrypt a message with a password.",
472
+ about: "AES-256-GCM with PBKDF2, and one string that carries everything needed to decrypt.",
473
+ tutorial: {
474
+ intro: "Send a secret through a channel you do not trust.",
475
+ steps: [
476
+ "Write the message and set a password.",
477
+ "Press Encrypt and copy the string that starts with mv1.",
478
+ "To read it back, switch to Decrypt, paste the string and the same password.",
479
+ ],
480
+ },
481
+ keywords: "aes encrypt decrypt password gcm pbkdf2 secret message",
482
+ load: () => import("./tools/aes-text.js"),
483
+ },
484
+ {
485
+ id: "rsa-key-pair",
486
+ category: "security",
487
+ name: "RSA Key Pair",
488
+ icon: "key-round",
489
+ note: "Make an RSA key pair in PEM form.",
490
+ about: "Generated in this tab with Web Crypto. Public as SPKI, private as PKCS#8.",
491
+ tutorial: {
492
+ intro: "Generate an RSA key pair and copy or download the PEM files.",
493
+ steps: [
494
+ "Pick a key size. 2048 is the common floor.",
495
+ "Press Generate pair and wait a moment for larger keys.",
496
+ "Copy or download the public and private PEM. Closing the page discards them.",
497
+ ],
498
+ },
499
+ keywords: "rsa key pair pem public private ssh jwt tls generate spki pkcs8",
500
+ load: () => import("./tools/rsa-key-pair.js"),
501
+ },
502
+ {
503
+ id: "bcrypt-hash",
504
+ category: "security",
505
+ name: "Bcrypt Hash",
506
+ icon: "hash",
507
+ note: "Hash a password with bcrypt, and check one.",
508
+ about: "Bcrypt with a cost factor you set, run in this tab with a vendored engine.",
509
+ tutorial: {
510
+ intro: "Make a bcrypt hash for a test login, or check a password against one you already have.",
511
+ steps: [
512
+ "Type a password and pick a cost factor. 10 is a common default.",
513
+ "Press Hash password. The timing shows how long this device takes.",
514
+ "To check, paste a stored hash and the password. The verdict updates as you type.",
515
+ ],
516
+ },
517
+ keywords: "bcrypt hash password login test salt cost compare verify",
518
+ load: () => import("./tools/bcrypt-hash.js"),
519
+ },
520
+ {
521
+ id: "password-policy",
522
+ category: "security",
523
+ name: "Password Policy Builder",
524
+ icon: "list-checks",
525
+ note: "Turn a password rule into text, a regex, and a checklist.",
526
+ about: "The wording, the pattern, and a live check all come from the same settings.",
527
+ tutorial: {
528
+ intro: "Write a password policy once, in every form you need.",
529
+ steps: [
530
+ "Set the length and the character classes.",
531
+ "Read the rule in plain words and copy the regex.",
532
+ "Type a sample password to see which part it fails.",
533
+ ],
534
+ },
535
+ keywords: "password policy regex rule requirements checklist enforce",
536
+ load: () => import("./tools/password-policy.js"),
537
+ },
538
+ {
539
+ id: "secret-scanner",
540
+ category: "security",
541
+ name: "Secret Scanner",
542
+ icon: "shield-alert",
543
+ note: "Find API keys and tokens before you paste them.",
544
+ about: "Known shapes for AWS, GitHub, Slack, Stripe, Google, JWTs and private keys.",
545
+ tutorial: {
546
+ intro: "Catch a leaked key before it goes into a chat.",
547
+ steps: [
548
+ "Paste the text, or drop a file.",
549
+ "Read the matches with their line numbers. Values are masked by default.",
550
+ "Rotate anything listed. A clean result is a hint, not a guarantee.",
551
+ ],
552
+ },
553
+ keywords: "secret scanner leak api key token private key aws github slack stripe jwt detect",
554
+ load: () => import("./tools/secret-scanner.js"),
555
+ },
556
+ {
557
+ id: "ssh-fingerprint",
558
+ category: "security",
559
+ name: "SSH Fingerprint",
560
+ icon: "fingerprint",
561
+ note: "Read the fingerprint of an SSH public key.",
562
+ about: "The SHA-256 form a server prints, plus the older MD5 form for comparison.",
563
+ tutorial: {
564
+ intro: "Compare a host fingerprint before you trust it.",
565
+ steps: [
566
+ "Paste the public key line, for example ssh-ed25519 AAAAC3...",
567
+ "Read the fingerprint and compare it with the one the host shows.",
568
+ "Use the MD5 form only for an old server that still prints that.",
569
+ ],
570
+ },
571
+ keywords: "ssh fingerprint public key sha256 md5 host verify known_hosts",
572
+ load: () => import("./tools/ssh-fingerprint.js"),
573
+ },
394
574
 
395
575
  {
396
576
  id: "uuid-generator",
@@ -0,0 +1,192 @@
1
+ // Encrypt a message with a password. AES-256-GCM with a PBKDF2 key, and the salt
2
+ // and IV packed into the output, so one string is enough to decrypt.
3
+ import {
4
+ el, icon, toast, bench, stack, panel, textArea, textInput, button, actions,
5
+ toolcard, notice, copyButton, output, chipRow,
6
+ } from "../core.js";
7
+
8
+ const ITERATIONS = 310_000;
9
+ const SALT_BYTES = 16;
10
+ const IV_BYTES = 12;
11
+ const VERSION = 1;
12
+
13
+ function toBase64(bytes) {
14
+ let binary = "";
15
+ for (const byte of bytes) binary += String.fromCharCode(byte);
16
+ return btoa(binary);
17
+ }
18
+
19
+ function fromBase64(text) {
20
+ const binary = atob(text.replace(/\s+/g, ""));
21
+ const out = new Uint8Array(binary.length);
22
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
23
+ return out;
24
+ }
25
+
26
+ async function deriveKey(password, salt) {
27
+ const base = await crypto.subtle.importKey(
28
+ "raw",
29
+ new TextEncoder().encode(password),
30
+ "PBKDF2",
31
+ false,
32
+ ["deriveKey"]
33
+ );
34
+ return crypto.subtle.deriveKey(
35
+ { name: "PBKDF2", salt, iterations: ITERATIONS, hash: "SHA-256" },
36
+ base,
37
+ { name: "AES-GCM", length: 256 },
38
+ false,
39
+ ["encrypt", "decrypt"]
40
+ );
41
+ }
42
+
43
+ export function mount(slot) {
44
+ let mode = "encrypt";
45
+
46
+ const message = textArea({ rows: 6, placeholder: "The message", "aria-label": "Message" });
47
+ const password = textInput({
48
+ type: "password",
49
+ placeholder: "A password you will remember",
50
+ "aria-label": "Password",
51
+ autocomplete: "new-password",
52
+ });
53
+ const show = el("input", { type: "checkbox", class: "m-0 size-4 accent-[var(--jade)]" });
54
+ const showLabel = el("label", { class: "inline-flex min-h-9 cursor-pointer items-center gap-2 text-sm text-ink-2" }, show, "Show password");
55
+
56
+ const modeRow = chipRow(
57
+ [
58
+ { value: "encrypt", label: "Encrypt" },
59
+ { value: "decrypt", label: "Decrypt" },
60
+ ],
61
+ {
62
+ value: "encrypt",
63
+ label: "Mode",
64
+ onChange: (value) => {
65
+ mode = value;
66
+ sync();
67
+ },
68
+ }
69
+ );
70
+
71
+ const error = notice("warn");
72
+ const result = output({ rows: 8 });
73
+ const summary = el("p", { class: "text-sm text-ink-2" });
74
+
75
+ function sync() {
76
+ const decrypting = mode === "decrypt";
77
+ message.placeholder = decrypting
78
+ ? "Paste the encrypted text, starting with mv1."
79
+ : "The message";
80
+ message.setAttribute("aria-label", decrypting ? "Encrypted text" : "Message");
81
+ run.textContent = decrypting ? "Decrypt text" : "Encrypt text";
82
+ result.write("");
83
+ summary.textContent = "";
84
+ error.write("");
85
+ }
86
+
87
+ async function act() {
88
+ error.write("");
89
+ result.write("");
90
+ summary.textContent = "";
91
+ const text = message.value;
92
+ const pass = password.value;
93
+ if (!text.trim()) {
94
+ error.write(mode === "decrypt" ? "Paste the encrypted text first." : "Write the message first.");
95
+ return;
96
+ }
97
+ if (!pass) {
98
+ error.write("Set a password first.");
99
+ return;
100
+ }
101
+
102
+ try {
103
+ if (mode === "encrypt") {
104
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
105
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
106
+ const key = await deriveKey(pass, salt);
107
+ const cipher = new Uint8Array(
108
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(text))
109
+ );
110
+ const packed = new Uint8Array(1 + SALT_BYTES + IV_BYTES + cipher.length);
111
+ packed[0] = VERSION;
112
+ packed.set(salt, 1);
113
+ packed.set(iv, 1 + SALT_BYTES);
114
+ packed.set(cipher, 1 + SALT_BYTES + IV_BYTES);
115
+ const encoded = `mv1.${toBase64(packed)}`;
116
+ result.write(encoded);
117
+ summary.textContent = `${encoded.length} characters · AES-256-GCM · PBKDF2-SHA-256, ${ITERATIONS.toLocaleString()} rounds`;
118
+ toast("Encrypted");
119
+ } else {
120
+ const clean = text.trim();
121
+ const body = clean.startsWith("mv1.") ? clean.slice(4) : clean;
122
+ let packed;
123
+ try {
124
+ packed = fromBase64(body);
125
+ } catch {
126
+ throw new Error("That is not Base64. Paste the whole string, starting with mv1.");
127
+ }
128
+ if (packed.length < 1 + SALT_BYTES + IV_BYTES + 16) throw new Error("That string is too short to be an encrypted message.");
129
+ if (packed[0] !== VERSION) throw new Error(`This was written by version ${packed[0]}, which this page does not know.`);
130
+ const salt = packed.slice(1, 1 + SALT_BYTES);
131
+ const iv = packed.slice(1 + SALT_BYTES, 1 + SALT_BYTES + IV_BYTES);
132
+ const cipher = packed.slice(1 + SALT_BYTES + IV_BYTES);
133
+ const key = await deriveKey(pass, salt);
134
+ let plain;
135
+ try {
136
+ plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
137
+ } catch {
138
+ throw new Error("Wrong password, or the text was changed after it was encrypted.");
139
+ }
140
+ const decoded = new TextDecoder().decode(plain);
141
+ result.write(decoded);
142
+ summary.textContent = `${decoded.length} characters`;
143
+ toast("Decrypted");
144
+ }
145
+ } catch (caught) {
146
+ error.write(caught?.message || "That did not work.");
147
+ }
148
+ }
149
+
150
+ show.addEventListener("change", () => {
151
+ password.type = show.checked ? "text" : "password";
152
+ });
153
+
154
+ const run = button("Encrypt text", { icon: "lock", kind: "fill", onclick: act });
155
+ const copy = copyButton(() => result.textContent, "Copy result");
156
+ const clear = button("Clear", {
157
+ icon: "trash",
158
+ kind: "quiet",
159
+ onclick: () => {
160
+ message.value = "";
161
+ password.value = "";
162
+ result.write("");
163
+ summary.textContent = "";
164
+ error.write("");
165
+ message.focus();
166
+ },
167
+ });
168
+
169
+ sync();
170
+
171
+ const shell = toolcard({
172
+ body: bench(
173
+ stack(
174
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Mode"), modeRow),
175
+ panel("Message", message),
176
+ panel("Password", password),
177
+ showLabel,
178
+ error
179
+ ),
180
+ stack(summary, result,
181
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
182
+ "AES-256-GCM with a key stretched by PBKDF2-SHA-256. The salt and IV are packed into the output, so one string is all you need to decrypt. A lost password cannot be recovered, which is the point."))
183
+ ),
184
+ foot: actions(clear, copy, run),
185
+ });
186
+
187
+ slot.append(shell.node);
188
+
189
+ return {};
190
+ }
191
+
192
+ export default { mount };