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/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="15 small tools that run in your browser. Passwords, QR codes, random draws, text and encoding. No accounts, no tracking." />
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/mevebu.css" />
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();
@@ -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-pal__key" }, "↵"),
244
+ el("kbd", { class: "mvb-kbd" }, "↵"),
245
245
  " open ",
246
- el("kbd", { class: "mvb-pal__key" }, "esc"),
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.0.0";
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 in the field on the left, one per line.",
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, output, actions, readPin, writePin, randomInt, toolcard } from "../core.js";
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 result = output({ rows: 1 });
12
- const tally = el("p", { class: "mvb-flip__tally", "aria-live": "polite" });
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
- result.write(decision ? `${decision}` : "");
16
- const total = heads + tails;
17
- tally.textContent = total ? `Heads ${heads}, tails ${tails}. ${total} flips.` : "No flips yet.";
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
- }, 400);
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", result), tally)
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
- const decode = (segment) => JSON.parse(new TextDecoder().decode(
7
- Uint8Array.from(atob(segment.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0))
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.textContent, "Copy token", "fill");
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
- try {
74
- const body = decode(token.split(".")[1]);
75
- const readable = [
76
- body.sub !== undefined ? `Subject: ${body.sub}` : "",
77
- body.name !== undefined ? `Name: ${body.name}` : "",
78
- body.exp !== undefined ? `Expires: ${new Date(body.exp * 1000).toLocaleString()}` : "No expiry set",
79
- ].filter(Boolean).join("\n");
80
- decoded.write(readable);
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), panel("Payload reading", decoded))
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 = el("p", { class: "note" });
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(count) {
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 fill = meter.querySelector(".mvb-meter__fill");
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
- strength.textContent = `${bits} bits, ${bits < 50 ? "weak" : bits < 90 ? "good" : "strong"}.`;
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), lengthRead, groups, error),
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: "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" },
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 = `${level.name} by length and patterns.`;
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 { el, numberInput, chipRow, output, notice, button, copyButton, bench, stack, panel, actions, toolcard } from "../core.js";
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, output, readPin, writePin, toolcard, actions } from "../core.js";
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 on the left", center, center);
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 = "#fff";
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 winner = randomInt(entries.length);
96
- const finish = (-Math.PI / 2) - (winner * slice + slice / 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
- status.textContent = entries[winner];
114
- result.write(entries[winner]);
115
- const chip = el("span", { class: "mvb-wheel__chip", role: "listitem" }, entries[winner]);
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
- result.write("");
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, result, historyBox)
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.0.0",
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
  }