mevebu 1.0.0 → 1.2.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/mevebu/app/mevebu.js +12 -15
- package/mevebu/app/tailwind.css +2742 -0
- package/mevebu/app/tailwind.src.css +1172 -0
- package/mevebu/index.html +2 -3
- package/mevebu/libs/js/core.js +3 -3
- package/mevebu/libs/js/registry.js +1 -1
- package/mevebu/libs/js/tools/coin-flip.js +34 -11
- package/mevebu/libs/js/tools/color-converter.js +1 -1
- package/mevebu/libs/js/tools/hash-generator.js +2 -2
- package/mevebu/libs/js/tools/hmac-generator.js +1 -2
- package/mevebu/libs/js/tools/jwt-generator.js +15 -16
- package/mevebu/libs/js/tools/name-picker.js +1 -1
- package/mevebu/libs/js/tools/password-generator.js +12 -7
- package/mevebu/libs/js/tools/password-strength.js +12 -12
- package/mevebu/libs/js/tools/qr-generator.js +1 -1
- package/mevebu/libs/js/tools/qr-scanner.js +1 -1
- package/mevebu/libs/js/tools/secure-token.js +1 -2
- package/mevebu/libs/js/tools/spin-wheel.js +25 -13
- package/package.json +6 -2
- package/mevebu/app/mevebu.css +0 -1207
- package/mevebu/libs/css/tools.css +0 -237
package/mevebu/index.html
CHANGED
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>mevebu, small tools that run in your browser</title>
|
|
7
|
-
<meta name="description" content="
|
|
7
|
+
<meta name="description" content="Small tools that run in your browser. Passwords, QR codes, random draws, text, dates and encoding. No accounts, no uploads, no tracking." />
|
|
8
8
|
<link rel="icon" type="image/png" href="./icon.png" />
|
|
9
|
-
<link rel="stylesheet" href="./app/
|
|
10
|
-
<link rel="stylesheet" href="./libs/css/tools.css" />
|
|
9
|
+
<link rel="stylesheet" href="./app/tailwind.css" />
|
|
11
10
|
<script type="module">
|
|
12
11
|
import { getTheme } from "./libs/js/core.js";
|
|
13
12
|
document.documentElement.dataset.theme = getTheme();
|
package/mevebu/libs/js/core.js
CHANGED
|
@@ -241,9 +241,9 @@ export function openPalette({ onPick } = {}) {
|
|
|
241
241
|
"p",
|
|
242
242
|
{ class: "mvb-pal__foot" },
|
|
243
243
|
"↑↓ navigate ",
|
|
244
|
-
el("kbd", { class: "mvb-
|
|
244
|
+
el("kbd", { class: "mvb-kbd" }, "↵"),
|
|
245
245
|
" open ",
|
|
246
|
-
el("kbd", { class: "mvb-
|
|
246
|
+
el("kbd", { class: "mvb-kbd" }, "esc"),
|
|
247
247
|
" close"
|
|
248
248
|
);
|
|
249
249
|
|
|
@@ -593,7 +593,7 @@ export function blank(iconName, text) {
|
|
|
593
593
|
// ---------- version ----------
|
|
594
594
|
|
|
595
595
|
// Kept in one place. The CLI reads the same value from package.json.
|
|
596
|
-
export const VERSION = "1.
|
|
596
|
+
export const VERSION = "1.2.0";
|
|
597
597
|
export const PACKAGE_NAME = "mevebu";
|
|
598
598
|
export const REPO_SLUG = "mevebu/mevebu";
|
|
599
599
|
|
|
@@ -40,7 +40,7 @@ export const TOOLS = [
|
|
|
40
40
|
tutorial: {
|
|
41
41
|
intro: "Add one option per line. The wheel picks one at random when you spin.",
|
|
42
42
|
steps: [
|
|
43
|
-
"Type your options
|
|
43
|
+
"Type your options, one per line.",
|
|
44
44
|
"Press Spin. The wheel eases to a stop on one entry.",
|
|
45
45
|
"Each pick is added to the history strip below. Press Clear past picks to reset.",
|
|
46
46
|
],
|
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
// Heads or tails with a running count that survives a page change.
|
|
2
|
-
import { el, button, bench, stack, panel,
|
|
2
|
+
import { el, button, bench, stack, panel, actions, readPin, writePin, randomInt, toolcard } from "../core.js";
|
|
3
|
+
|
|
4
|
+
// A toss is one flip: a click while the coin is still turning replaces the turn
|
|
5
|
+
// in progress instead of queueing another one behind it.
|
|
6
|
+
const TOSS_MS = 400;
|
|
7
|
+
|
|
8
|
+
/** A tally tile. The value is the node the tool writes to. */
|
|
9
|
+
function tile(key) {
|
|
10
|
+
const value = el("span", { class: "mvb-stat__v" }, "0");
|
|
11
|
+
const row = el("div", { class: "mvb-stat" }, value, el("span", { class: "mvb-stat__k" }, key));
|
|
12
|
+
return { row, value };
|
|
13
|
+
}
|
|
3
14
|
|
|
4
15
|
export function mount(slot) {
|
|
5
16
|
const PIN = "coin-flip";
|
|
6
17
|
const saved = readPin(PIN, { heads: 0, tails: 0 });
|
|
7
18
|
let heads = Number(saved.heads) || 0;
|
|
8
19
|
let tails = Number(saved.tails) || 0;
|
|
20
|
+
let timer = 0;
|
|
9
21
|
|
|
10
|
-
const coin = el("div", { class: "mvb-coin", role: "img", "aria-label": "Coin" }, "?");
|
|
11
|
-
const
|
|
12
|
-
const
|
|
22
|
+
const coin = el("div", { class: "mvb-coin", role: "img", "aria-label": "Coin, not flipped yet" }, "?");
|
|
23
|
+
const verdict = el("p", { class: "mvb-flip__result min-h-7", "aria-live": "polite" });
|
|
24
|
+
const headsTile = tile("Heads");
|
|
25
|
+
const tailsTile = tile("Tails");
|
|
26
|
+
const totalTile = tile("Total");
|
|
27
|
+
const stats = el("div", { class: "mvb-stats" }, headsTile.row, tailsTile.row, totalTile.row);
|
|
13
28
|
|
|
14
29
|
function render(decision) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
verdict.textContent = decision;
|
|
31
|
+
coin.setAttribute("aria-label", decision ? `Coin: ${decision.toLowerCase()}` : "Coin, not flipped yet");
|
|
32
|
+
headsTile.value.textContent = String(heads);
|
|
33
|
+
tailsTile.value.textContent = String(tails);
|
|
34
|
+
totalTile.value.textContent = String(heads + tails);
|
|
18
35
|
writePin(PIN, { heads, tails });
|
|
19
36
|
}
|
|
20
37
|
|
|
@@ -22,19 +39,21 @@ export function mount(slot) {
|
|
|
22
39
|
const resetButton = button("Reset the count", { kind: "quiet" });
|
|
23
40
|
|
|
24
41
|
flipButton.addEventListener("click", () => {
|
|
42
|
+
clearTimeout(timer);
|
|
25
43
|
coin.classList.remove("mvb-toss");
|
|
26
44
|
void coin.offsetWidth;
|
|
27
45
|
coin.classList.add("mvb-toss");
|
|
28
|
-
setTimeout(() => {
|
|
46
|
+
timer = setTimeout(() => {
|
|
29
47
|
const isHeads = randomInt(2) === 0;
|
|
30
48
|
if (isHeads) heads++;
|
|
31
49
|
else tails++;
|
|
32
50
|
coin.textContent = isHeads ? "H" : "T";
|
|
33
51
|
render(isHeads ? "Heads" : "Tails");
|
|
34
|
-
},
|
|
52
|
+
}, TOSS_MS);
|
|
35
53
|
});
|
|
36
54
|
|
|
37
55
|
resetButton.addEventListener("click", () => {
|
|
56
|
+
clearTimeout(timer);
|
|
38
57
|
heads = 0;
|
|
39
58
|
tails = 0;
|
|
40
59
|
coin.textContent = "?";
|
|
@@ -44,14 +63,18 @@ export function mount(slot) {
|
|
|
44
63
|
const card = toolcard({
|
|
45
64
|
body: bench(
|
|
46
65
|
stack(panel("Coin", coin)),
|
|
47
|
-
stack(panel("Result",
|
|
66
|
+
stack(panel("Result", verdict), stats)
|
|
48
67
|
),
|
|
49
68
|
foot: actions(resetButton, flipButton),
|
|
50
69
|
});
|
|
51
70
|
slot.append(card.node);
|
|
52
71
|
render("");
|
|
53
72
|
|
|
54
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
unmount() {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
export default { mount };
|
|
@@ -91,7 +91,7 @@ export function mount(slot) {
|
|
|
91
91
|
const cssField = readonly("CSS variable");
|
|
92
92
|
|
|
93
93
|
const error = notice("error");
|
|
94
|
-
const reading = el("p", { class: "note" });
|
|
94
|
+
const reading = el("p", { class: "mvb-note" });
|
|
95
95
|
|
|
96
96
|
function show(color, keepPicker) {
|
|
97
97
|
const hex = hexOf(color);
|
|
@@ -6,7 +6,7 @@ const ALGORITHMS = ["SHA-1", "SHA-256", "SHA-384", "SHA-512"];
|
|
|
6
6
|
export function mount(slot) {
|
|
7
7
|
const source = textArea({ value: "mevebu", rows: 5, "aria-label": "Text to hash", placeholder: "Type or paste text" });
|
|
8
8
|
const result = output();
|
|
9
|
-
const digest = el("p", { class: "note" });
|
|
9
|
+
const digest = el("p", { class: "mvb-note" });
|
|
10
10
|
const error = notice("error");
|
|
11
11
|
|
|
12
12
|
let algorithm = "SHA-256";
|
|
@@ -41,11 +41,11 @@ export function mount(slot) {
|
|
|
41
41
|
const copy = copyButton(() => result.textContent, "Copy hash");
|
|
42
42
|
|
|
43
43
|
const card = toolcard({
|
|
44
|
-
head: actions(copy),
|
|
45
44
|
body: bench(
|
|
46
45
|
stack(panel("Algorithm", chips), panel("Text", source)),
|
|
47
46
|
stack(panel("Hash", result), digest, error)
|
|
48
47
|
),
|
|
48
|
+
foot: actions(copy),
|
|
49
49
|
});
|
|
50
50
|
slot.append(card.node);
|
|
51
51
|
run();
|
|
@@ -47,8 +47,7 @@ export function mount(slot) {
|
|
|
47
47
|
stack(
|
|
48
48
|
panel("Message", message),
|
|
49
49
|
panel("Secret key", el("div", { class: "mvb-secret" }, secret, showButton)),
|
|
50
|
-
panel("Hash", hashField)
|
|
51
|
-
el("p", { class: "mvb-note" }, "The message and key stay in this tab. Do not paste a production secret into a site you do not trust.")
|
|
50
|
+
panel("Hash", hashField)
|
|
52
51
|
),
|
|
53
52
|
stack(panel("Signature", result), error)
|
|
54
53
|
),
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { el, icon, bench, stack, panel, textArea, textInput, notice, copyButton, output, debounce, actions, toolcard } from "../core.js";
|
|
4
4
|
|
|
5
5
|
const encoder = new TextEncoder();
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
|
|
7
|
+
// Only claims that carry a value the JSON does not show by itself: a Unix
|
|
8
|
+
// timestamp means nothing to read, the date behind it does.
|
|
9
|
+
const TIME_CLAIMS = [["iat", "Issued at"], ["nbf", "Not before"], ["exp", "Expires"]];
|
|
9
10
|
|
|
10
11
|
const toUrl = (bytes) => {
|
|
11
12
|
let text = "";
|
|
@@ -31,8 +32,9 @@ export function mount(slot) {
|
|
|
31
32
|
const error = notice("error");
|
|
32
33
|
const tokenOut = output({ tag: "textarea", rows: 4 });
|
|
33
34
|
const decoded = output({ rows: 4 });
|
|
35
|
+
const timePanel = panel("Time claims", decoded);
|
|
34
36
|
|
|
35
|
-
const copyBtn = copyButton(() => tokenOut.
|
|
37
|
+
const copyBtn = copyButton(() => tokenOut.value, "Copy token", "fill");
|
|
36
38
|
|
|
37
39
|
showButton.addEventListener("click", () => {
|
|
38
40
|
const visible = secretField.type === "text";
|
|
@@ -70,17 +72,14 @@ export function mount(slot) {
|
|
|
70
72
|
return;
|
|
71
73
|
}
|
|
72
74
|
tokenOut.write(token);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
} catch {
|
|
82
|
-
decoded.write("");
|
|
83
|
-
}
|
|
75
|
+
const times = TIME_CLAIMS
|
|
76
|
+
.filter(([key]) => payload[key] !== undefined)
|
|
77
|
+
.map(([key, label]) => {
|
|
78
|
+
const value = Number(payload[key]);
|
|
79
|
+
return Number.isFinite(value) ? `${label}: ${new Date(value * 1000).toLocaleString()}` : `${label}: ${payload[key]}`;
|
|
80
|
+
});
|
|
81
|
+
timePanel.hidden = !times.length;
|
|
82
|
+
decoded.write(times.join("\n"));
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
// Live signing. The textareas are the only slow field: invalid JSON while a
|
|
@@ -98,7 +97,7 @@ export function mount(slot) {
|
|
|
98
97
|
panel("Secret", el("div", { class: "mvb-secret" }, secretField, showButton)),
|
|
99
98
|
error
|
|
100
99
|
),
|
|
101
|
-
stack(panel("Token", tokenOut),
|
|
100
|
+
stack(panel("Token", tokenOut), timePanel)
|
|
102
101
|
),
|
|
103
102
|
foot: actions(copyBtn),
|
|
104
103
|
});
|
|
@@ -8,7 +8,7 @@ export function mount(slot) {
|
|
|
8
8
|
const listField = textArea({ value: saved.entries ?? "", rows: 8, "aria-label": "Entries, one per line", placeholder: "One entry per line" });
|
|
9
9
|
const countField = numberInput({ value: saved.winners ?? "1", min: "1", max: "500", "aria-label": "How many to draw" });
|
|
10
10
|
const uniqueBox = checkbox("No repeats", { checked: saved.unique !== false });
|
|
11
|
-
const notify = el("p", { class: "note" });
|
|
11
|
+
const notify = el("p", { class: "mvb-note" });
|
|
12
12
|
|
|
13
13
|
const drawButton = button("Draw", { icon: "play", kind: "fill" });
|
|
14
14
|
const copyButton = button("Copy", { icon: "copy" });
|
|
@@ -11,11 +11,9 @@ const SETS = {
|
|
|
11
11
|
export function mount(slot) {
|
|
12
12
|
const lengthField = rangeInput({ min: "6", max: "64", value: "16", "aria-label": "Length" });
|
|
13
13
|
const lengthLabel = el("span", { class: "mvb-field__label" });
|
|
14
|
-
const lengthRead = el("p", { class: "note" });
|
|
15
14
|
|
|
16
15
|
function showLength() {
|
|
17
16
|
lengthLabel.textContent = `Length: ${lengthField.value}`;
|
|
18
|
-
lengthRead.textContent = `${lengthField.value} characters.`;
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
const groups = toggleRow([
|
|
@@ -27,7 +25,7 @@ export function mount(slot) {
|
|
|
27
25
|
|
|
28
26
|
const secret = textInput({ class: "mvb-in mvb-in--mono", readonly: true, "aria-label": "Password", placeholder: "Press Generate" });
|
|
29
27
|
const meter = el("div", { class: "mvb-meter", role: "img", "aria-label": "Strength" }, el("div", { class: "mvb-meter__fill" }));
|
|
30
|
-
const strength =
|
|
28
|
+
const strength = notice("note");
|
|
31
29
|
const error = notice("error");
|
|
32
30
|
|
|
33
31
|
const generateButton = button("Generate", { kind: "fill" });
|
|
@@ -37,10 +35,15 @@ export function mount(slot) {
|
|
|
37
35
|
return groups.values().map((key) => SETS[key]).join("");
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
function draw(
|
|
38
|
+
function draw() {
|
|
41
39
|
const source = pool();
|
|
40
|
+
const fill = meter.querySelector(".mvb-meter__fill");
|
|
42
41
|
if (!source) {
|
|
43
42
|
secret.value = "";
|
|
43
|
+
fill.style.width = "0%";
|
|
44
|
+
fill.className = "mvb-meter__fill";
|
|
45
|
+
meter.setAttribute("aria-label", "Strength");
|
|
46
|
+
strength.write("");
|
|
44
47
|
error.write("Pick at least one group above.");
|
|
45
48
|
return;
|
|
46
49
|
}
|
|
@@ -60,10 +63,12 @@ export function mount(slot) {
|
|
|
60
63
|
secret.value = password;
|
|
61
64
|
showLength();
|
|
62
65
|
const bits = Math.round(length * Math.log2(source.length));
|
|
63
|
-
const
|
|
66
|
+
const word = bits < 50 ? "weak" : bits < 90 ? "good" : "strong";
|
|
64
67
|
fill.style.width = `${Math.min(100, (bits / 128) * 100)}%`;
|
|
65
68
|
fill.className = `mvb-meter__fill ${bits < 50 ? "mvb-weak" : bits < 90 ? "mvb-ok" : "mvb-strong"}`;
|
|
66
|
-
|
|
69
|
+
// The bar is colour only, so the reading it stands for also goes on the bar itself.
|
|
70
|
+
meter.setAttribute("aria-label", `Strength: ${bits} bits, ${word}`);
|
|
71
|
+
strength.write(`${bits} bits, ${word}.`);
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
lengthField.addEventListener("input", () => { showLength(); draw(); });
|
|
@@ -76,7 +81,7 @@ export function mount(slot) {
|
|
|
76
81
|
|
|
77
82
|
const card = toolcard({
|
|
78
83
|
body: bench(
|
|
79
|
-
stack(panel(lengthLabel, lengthField),
|
|
84
|
+
stack(panel(lengthLabel, lengthField), groups, error),
|
|
80
85
|
stack(panel("Password", secret), meter, strength)
|
|
81
86
|
),
|
|
82
87
|
foot: actions(copyButton, generateButton),
|
|
@@ -14,12 +14,12 @@ const GROUPS = [
|
|
|
14
14
|
];
|
|
15
15
|
|
|
16
16
|
const LEVELS = [
|
|
17
|
-
{ name: "
|
|
18
|
-
{ name: "
|
|
19
|
-
{ name: "
|
|
20
|
-
{ name: "
|
|
21
|
-
{ name: "
|
|
22
|
-
{ name: "
|
|
17
|
+
{ name: "Very weak", cls: "mvb-weak" },
|
|
18
|
+
{ name: "Weak", cls: "mvb-weak" },
|
|
19
|
+
{ name: "Fair", cls: "mvb-ok" },
|
|
20
|
+
{ name: "Good", cls: "mvb-ok" },
|
|
21
|
+
{ name: "Strong", cls: "mvb-strong" },
|
|
22
|
+
{ name: "Very strong", cls: "mvb-strong" },
|
|
23
23
|
];
|
|
24
24
|
|
|
25
25
|
function analyze(value) {
|
|
@@ -53,7 +53,7 @@ export function mount(slot) {
|
|
|
53
53
|
const field = passwordInput({ autocomplete: "off", "aria-label": "Password", placeholder: "Type a password" });
|
|
54
54
|
const showButton = button("Show", { icon: "eye" });
|
|
55
55
|
const meter = el("div", { class: "mvb-meter", role: "img", "aria-label": "Strength" }, el("div", { class: "mvb-meter__fill" }));
|
|
56
|
-
const label = el("p", { class: "note" });
|
|
56
|
+
const label = el("p", { class: "mvb-note text-[1.0625rem] font-bold text-ink" });
|
|
57
57
|
const lengthValue = el("span", { class: "mvb-kv__v" }, "-");
|
|
58
58
|
const groupsValue = el("span", { class: "mvb-kv__v" }, "-");
|
|
59
59
|
const kv = el("div", { class: "mvb-kv" }, kvRow("Length", lengthValue), kvRow("Character groups", groupsValue));
|
|
@@ -75,6 +75,7 @@ export function mount(slot) {
|
|
|
75
75
|
fill.style.width = "0%";
|
|
76
76
|
fill.className = "mvb-meter__fill";
|
|
77
77
|
label.textContent = "";
|
|
78
|
+
meter.setAttribute("aria-label", "Strength");
|
|
78
79
|
lengthValue.textContent = "-";
|
|
79
80
|
groupsValue.textContent = "-";
|
|
80
81
|
status.write("");
|
|
@@ -85,7 +86,9 @@ export function mount(slot) {
|
|
|
85
86
|
const level = LEVELS[score];
|
|
86
87
|
fill.style.width = `${(score / 5) * 100}%`;
|
|
87
88
|
fill.className = `mvb-meter__fill ${level.cls}`;
|
|
88
|
-
label.textContent =
|
|
89
|
+
label.textContent = level.name;
|
|
90
|
+
// The bar is colour only, so the reading it stands for also goes on the bar itself.
|
|
91
|
+
meter.setAttribute("aria-label", `Strength: ${level.name}`);
|
|
89
92
|
lengthValue.textContent = `${length} characters`;
|
|
90
93
|
groupsValue.textContent = `${groups} of 4`;
|
|
91
94
|
for (const text of why) {
|
|
@@ -101,10 +104,7 @@ export function mount(slot) {
|
|
|
101
104
|
|
|
102
105
|
const card = toolcard({
|
|
103
106
|
body: bench(
|
|
104
|
-
stack(
|
|
105
|
-
panel("Password", el("div", { class: "mvb-secret" }, field, showButton)),
|
|
106
|
-
el("p", { class: "mvb-note" }, "The check runs in this tab. Do not type a real password into a site you do not trust.")
|
|
107
|
-
),
|
|
107
|
+
stack(panel("Password", el("div", { class: "mvb-secret" }, field, showButton))),
|
|
108
108
|
stack(meter, label, kv, hints, status)
|
|
109
109
|
),
|
|
110
110
|
foot: actions(copyButton(() => report, "Copy report", "fill")),
|
|
@@ -7,7 +7,7 @@ export function mount(slot) {
|
|
|
7
7
|
const content = textArea({ rows: 4, value: "https://mevebu.app", "aria-label": "Text or link", placeholder: "Text or link" });
|
|
8
8
|
const sizeField = numberInput({ value: "256", min: "120", max: "1024", step: "8", "aria-label": "Size in pixels" });
|
|
9
9
|
const canvas = el("canvas", { class: "mvb-qrcode", role: "img", "aria-label": "QR code" });
|
|
10
|
-
const reading = el("p", { class: "note" });
|
|
10
|
+
const reading = el("p", { class: "mvb-note" });
|
|
11
11
|
const error = notice("error");
|
|
12
12
|
let svg = "";
|
|
13
13
|
|
|
@@ -21,7 +21,7 @@ function loadJsQR() {
|
|
|
21
21
|
export function mount(slot) {
|
|
22
22
|
const video = el("video", { class: "mvb-qrview", playsinline: true, muted: true, hidden: true });
|
|
23
23
|
const fileField = el("input", { type: "file", accept: "image/*", hidden: true, "aria-label": "Image file" });
|
|
24
|
-
const status = el("p", { class: "note" });
|
|
24
|
+
const status = el("p", { class: "mvb-note" });
|
|
25
25
|
const found = output({ rows: 3 });
|
|
26
26
|
const error = notice("error");
|
|
27
27
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Random tokens from the browser random source, in hex or URL-safe Base64.
|
|
2
|
-
import {
|
|
2
|
+
import { numberInput, chipRow, output, notice, button, copyButton, bench, stack, panel, actions, toolcard } from "../core.js";
|
|
3
3
|
|
|
4
4
|
function toHex(bytes) {
|
|
5
5
|
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
@@ -44,7 +44,6 @@ export function mount(slot) {
|
|
|
44
44
|
stack(
|
|
45
45
|
panel("Bytes", bytesField),
|
|
46
46
|
panel("Format", formats),
|
|
47
|
-
el("p", { class: "mvb-note" }, "The token is drawn from the browser random source in this tab. Store real tokens safely."),
|
|
48
47
|
error
|
|
49
48
|
),
|
|
50
49
|
stack(panel("Token", result))
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
// Spins through your options and lands on one. Canvas wheel with an eased
|
|
2
2
|
// spin, driven by the browser random source, so every option has equal odds.
|
|
3
|
-
import { el, toast, randomInt, bench, stage, stack, panel, textArea, button,
|
|
3
|
+
import { el, toast, copyButton, randomInt, bench, stage, stack, panel, textArea, button, readPin, writePin, toolcard, actions } from "../core.js";
|
|
4
4
|
|
|
5
5
|
const SEGMENTS = ["#2456D6", "#151D2E", "#5F8BFF", "#4B5871", "#1A43AD", "#69748C", "#1D2A4D", "#C2CBDF"];
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The slice label, in whichever ink reads better on that fill. Every fill in the
|
|
9
|
+
* palette sits far enough from mid grey that one of the two clears 4.5:1, so a
|
|
10
|
+
* pale slice never gets white text.
|
|
11
|
+
*/
|
|
12
|
+
function textOn(hex) {
|
|
13
|
+
const linear = (value) => (value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
|
|
14
|
+
const [r, g, b] = [1, 3, 5].map((at) => linear(parseInt(hex.slice(at, at + 2), 16) / 255));
|
|
15
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.2056 ? "#151D2E" : "#FFFFFF";
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
function entriesFrom(text) {
|
|
8
19
|
return text.split(/\n+/).map((line) => line.trim()).filter(Boolean);
|
|
9
20
|
}
|
|
@@ -15,18 +26,18 @@ export function mount(slot) {
|
|
|
15
26
|
const listField = textArea({ value: saved.entries ?? "", "aria-label": "Options, one per line", placeholder: "One option per line" });
|
|
16
27
|
const canvas = el("canvas", { class: "mvb-wheel", width: 360, height: 360, role: "img", "aria-label": "The wheel" });
|
|
17
28
|
const context = canvas.getContext("2d");
|
|
18
|
-
const result = output({ rows: 2 });
|
|
19
29
|
const historyBox = el("div", { class: "mvb-wheel__history", role: "list" });
|
|
20
30
|
|
|
21
31
|
const spinButton = button("Spin", { icon: "rotate-cw", kind: "fill" });
|
|
22
32
|
const clearButton = button("Clear past picks", { icon: "trash", kind: "quiet" });
|
|
33
|
+
const copyWinner = copyButton(() => winner, "Copy winner");
|
|
23
34
|
|
|
24
35
|
const status = el("div", { class: "mvb-wheel__status", "aria-live": "polite" }, "Add options, then spin.");
|
|
25
|
-
result.write("");
|
|
26
36
|
|
|
27
37
|
let rotation = 0;
|
|
28
38
|
let busy = false;
|
|
29
39
|
let frame = 0;
|
|
40
|
+
let winner = "";
|
|
30
41
|
|
|
31
42
|
function draw() {
|
|
32
43
|
const entries = entriesFrom(listField.value);
|
|
@@ -44,7 +55,7 @@ export function mount(slot) {
|
|
|
44
55
|
context.fillStyle = faint;
|
|
45
56
|
context.font = "15px system-ui";
|
|
46
57
|
context.textAlign = "center";
|
|
47
|
-
context.fillText(entries.length ? "Add one more option to spin" : "Type options
|
|
58
|
+
context.fillText(entries.length ? "Add one more option to spin" : "Type options to spin", center, center);
|
|
48
59
|
status.textContent = entries.length ? "One more option to go." : "Add options, then spin.";
|
|
49
60
|
return;
|
|
50
61
|
}
|
|
@@ -61,7 +72,7 @@ export function mount(slot) {
|
|
|
61
72
|
context.translate(center, center);
|
|
62
73
|
context.rotate(a0 + slice / 2);
|
|
63
74
|
context.textAlign = "right";
|
|
64
|
-
context.fillStyle =
|
|
75
|
+
context.fillStyle = textOn(SEGMENTS[i % SEGMENTS.length]);
|
|
65
76
|
context.font = "600 13px system-ui, sans-serif";
|
|
66
77
|
const label = entry.length > 13 ? `${entry.slice(0, 12)}…` : entry;
|
|
67
78
|
context.fillText(label, radius - 12, 4);
|
|
@@ -92,8 +103,8 @@ export function mount(slot) {
|
|
|
92
103
|
spinButton.disabled = true;
|
|
93
104
|
status.dataset.winner = "";
|
|
94
105
|
const slice = (Math.PI * 2) / entries.length;
|
|
95
|
-
const
|
|
96
|
-
const finish = (-Math.PI / 2) - (
|
|
106
|
+
const winnerIndex = randomInt(entries.length);
|
|
107
|
+
const finish = (-Math.PI / 2) - (winnerIndex * slice + slice / 2);
|
|
97
108
|
const fullTurns = Math.PI * 2 * (5 + randomInt(3));
|
|
98
109
|
const delta = ((finish - rotation) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) + fullTurns;
|
|
99
110
|
const start = rotation;
|
|
@@ -110,9 +121,9 @@ export function mount(slot) {
|
|
|
110
121
|
busy = false;
|
|
111
122
|
spinButton.disabled = false;
|
|
112
123
|
status.dataset.winner = "1";
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const chip = el("span", { class: "mvb-wheel__chip", role: "listitem" },
|
|
124
|
+
winner = entries[winnerIndex];
|
|
125
|
+
status.textContent = winner;
|
|
126
|
+
const chip = el("span", { class: "mvb-wheel__chip", role: "listitem" }, winner);
|
|
116
127
|
historyBox.prepend(chip);
|
|
117
128
|
while (historyBox.children.length > 12) historyBox.lastChild.remove();
|
|
118
129
|
}
|
|
@@ -122,13 +133,14 @@ export function mount(slot) {
|
|
|
122
133
|
|
|
123
134
|
listField.addEventListener("input", () => {
|
|
124
135
|
status.dataset.winner = "";
|
|
136
|
+
winner = "";
|
|
125
137
|
writePin(PIN, { entries: listField.value });
|
|
126
138
|
draw();
|
|
127
139
|
});
|
|
128
140
|
spinButton.addEventListener("click", spin);
|
|
129
141
|
clearButton.addEventListener("click", () => {
|
|
130
142
|
historyBox.replaceChildren();
|
|
131
|
-
|
|
143
|
+
winner = "";
|
|
132
144
|
status.dataset.winner = "";
|
|
133
145
|
status.textContent = "Past picks cleared.";
|
|
134
146
|
});
|
|
@@ -136,9 +148,9 @@ export function mount(slot) {
|
|
|
136
148
|
const card = toolcard({
|
|
137
149
|
body: bench(
|
|
138
150
|
stack(panel("Options, one per line", listField)),
|
|
139
|
-
stage(canvas, status,
|
|
151
|
+
stage(canvas, status, historyBox)
|
|
140
152
|
),
|
|
141
|
-
foot: actions(clearButton, spinButton),
|
|
153
|
+
foot: actions(copyWinner, clearButton, spinButton),
|
|
142
154
|
});
|
|
143
155
|
slot.append(card.node);
|
|
144
156
|
draw();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mevebu",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Small tools that run in your browser. No accounts, no uploads, no tracking.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"LICENSE"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
+
"css": "tailwindcss -i mevebu/app/tailwind.src.css -o mevebu/app/tailwind.css",
|
|
18
|
+
"css:watch": "tailwindcss -i mevebu/app/tailwind.src.css -o mevebu/app/tailwind.css --watch",
|
|
17
19
|
"icons": "node scripts/vendor-icons.mjs",
|
|
18
20
|
"dev": "node bin/mevebu.js",
|
|
19
21
|
"start": "node bin/mevebu.js",
|
|
@@ -42,8 +44,10 @@
|
|
|
42
44
|
"node": ">=18"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
47
|
+
"@tailwindcss/cli": "^4.3.3",
|
|
45
48
|
"jsqr": "^1.4.0",
|
|
46
49
|
"lucide": "^1.57.0",
|
|
47
|
-
"qrcode-generator": "^2.0.4"
|
|
50
|
+
"qrcode-generator": "^2.0.4",
|
|
51
|
+
"tailwindcss": "^4.3.3"
|
|
48
52
|
}
|
|
49
53
|
}
|