mevebu 1.1.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.
@@ -32,7 +32,7 @@ function searchButton() {
32
32
  },
33
33
  icon("search"),
34
34
  el("span", { class: "mvb-searchbtn__txt" }, "Search"),
35
- el("kbd", { class: "mvb-searchbtn__key" }, "⌘K")
35
+ el("kbd", { class: "mvb-kbd" }, "⌘K")
36
36
  );
37
37
  return btn;
38
38
  }
@@ -106,9 +106,6 @@ function openHelp(tool) {
106
106
  el("p", { class: "mvb-help__note" }, tool.tutorial?.intro ?? tool.about),
107
107
  el("ol", { class: "mvb-help__steps" },
108
108
  ...(tool.tutorial?.steps ?? []).map((step) => el("li", {}, step))),
109
- el("p", { class: "mvb-help__note" },
110
- "Open the tool: ",
111
- el("a", { class: "mvb-link", href: toolHref(tool) }, toolHref(tool).slice(1))),
112
109
  ];
113
110
  modal({
114
111
  label: `${tool.name} how to use it`,
@@ -363,7 +360,7 @@ function filterBar(placeholder = "Search tools") {
363
360
  { class: "mvb-filter", role: "search", onsubmit: (event) => event.preventDefault() },
364
361
  el("span", { class: "mvb-filter__icon" }, icon("search")),
365
362
  input,
366
- el("kbd", { class: "mvb-filter__key" }, "/")
363
+ el("kbd", { class: "mvb-kbd" }, "/")
367
364
  );
368
365
  }
369
366
 
@@ -457,20 +457,6 @@
457
457
  font-size: var(--text-sm);
458
458
  line-height: var(--tw-leading, var(--text-sm--line-height));
459
459
  }
460
- .mvb-searchbtn__key {
461
- border-radius: 4px;
462
- border-style: var(--tw-border-style);
463
- border-width: 1px;
464
- border-color: var(--mvb-orbit);
465
- background-color: var(--mvb-deck);
466
- padding-inline: calc(var(--spacing) * 1.5);
467
- padding-block: 1px;
468
- font-family: var(--mvb-sans);
469
- font-size: 0.71875rem;
470
- --tw-font-weight: var(--font-weight-semibold);
471
- font-weight: var(--font-weight-semibold);
472
- color: var(--mvb-ink-3);
473
- }
474
460
  .mvb-sheet {
475
461
  display: grid;
476
462
  border-top-style: var(--tw-border-style);
@@ -530,13 +516,14 @@
530
516
  border-radius: 5px;
531
517
  border-style: var(--tw-border-style);
532
518
  border-width: 1px;
533
- border-color: var(--mvb-orbit);
534
- background-color: var(--mvb-deck-2);
519
+ border-color: var(--mvb-orbit-hard);
535
520
  padding-inline: 7px;
536
521
  padding-block: 1px;
537
522
  font-family: var(--mvb-sans);
538
- font-size: 0.78125rem;
539
- color: var(--mvb-ink-2);
523
+ font-size: 0.8125rem;
524
+ --tw-leading: 1.45;
525
+ line-height: 1.45;
526
+ color: var(--mvb-ink-3);
540
527
  }
541
528
  .mvb-hero {
542
529
  position: relative;
@@ -875,17 +862,6 @@
875
862
  .mvb-filter__in::placeholder {
876
863
  color: var(--mvb-ink-3);
877
864
  }
878
- .mvb-filter__key {
879
- border-radius: 5px;
880
- border-style: var(--tw-border-style);
881
- border-width: 1px;
882
- border-color: var(--mvb-orbit);
883
- padding-inline: 7px;
884
- padding-block: calc(var(--spacing) * 0.5);
885
- font-family: var(--mvb-sans);
886
- font-size: 0.75rem;
887
- color: var(--mvb-ink-3);
888
- }
889
865
  .mvb-filter__meta {
890
866
  margin-top: calc(var(--spacing) * 2.5);
891
867
  display: flex;
@@ -896,9 +872,6 @@
896
872
  line-height: var(--tw-leading, var(--text-sm--line-height));
897
873
  color: var(--mvb-ink-3);
898
874
  }
899
- .mvb-tally {
900
- color: var(--mvb-danger);
901
- }
902
875
  .mvb-lead {
903
876
  margin-top: clamp(28px, 5vw, 56px);
904
877
  max-width: 20ch;
@@ -1679,7 +1652,7 @@
1679
1652
  margin: 0px;
1680
1653
  padding-inline: 18px;
1681
1654
  padding-top: calc(var(--spacing) * 2);
1682
- font-size: 0.78125rem;
1655
+ font-size: 0.8125rem;
1683
1656
  color: var(--mvb-ink-3);
1684
1657
  }
1685
1658
  .mvb-pal__list {
@@ -1751,7 +1724,7 @@
1751
1724
  color: var(--mvb-ink-2);
1752
1725
  }
1753
1726
  .mvb-pal__cat {
1754
- font-size: 0.78125rem;
1727
+ font-size: 0.8125rem;
1755
1728
  white-space: nowrap;
1756
1729
  color: var(--mvb-ink-3);
1757
1730
  }
@@ -1772,21 +1745,9 @@
1772
1745
  border-color: var(--mvb-orbit);
1773
1746
  padding-inline: 18px;
1774
1747
  padding-block: calc(var(--spacing) * 2.5);
1775
- font-size: 0.78125rem;
1748
+ font-size: 0.8125rem;
1776
1749
  color: var(--mvb-ink-3);
1777
1750
  }
1778
- .mvb-pal__key {
1779
- border-radius: 4px;
1780
- border-style: var(--tw-border-style);
1781
- border-width: 1px;
1782
- border-color: var(--mvb-orbit);
1783
- background-color: var(--mvb-deck-2);
1784
- padding-inline: calc(var(--spacing) * 1.5);
1785
- padding-block: 1px;
1786
- font-family: var(--mvb-sans);
1787
- font-size: 0.6875rem;
1788
- color: var(--mvb-ink-2);
1789
- }
1790
1751
  .mvb-veil--help {
1791
1752
  padding-top: clamp(60px, 10vh, 120px);
1792
1753
  }
@@ -1858,20 +1819,13 @@
1858
1819
  place-items: center;
1859
1820
  border-radius: calc(infinity * 1px);
1860
1821
  background-color: var(--mvb-nebula);
1861
- font-size: 0.75rem;
1822
+ font-size: 0.8125rem;
1862
1823
  --tw-font-weight: var(--font-weight-bold);
1863
1824
  font-weight: var(--font-weight-bold);
1864
1825
  color: var(--mvb-comet);
1865
1826
  --tw-numeric-spacing: tabular-nums;
1866
1827
  font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
1867
1828
  }
1868
- .mvb-help__actions {
1869
- margin-top: calc(var(--spacing) * 5);
1870
- display: flex;
1871
- flex-wrap: wrap;
1872
- justify-content: flex-end;
1873
- gap: calc(var(--spacing) * 2);
1874
- }
1875
1829
  .mvb-foot {
1876
1830
  margin-top: clamp(56px, 8vw, 96px);
1877
1831
  border-top-style: var(--tw-border-style);
@@ -1932,7 +1886,7 @@
1932
1886
  .mvb-foot__nav h4 {
1933
1887
  margin: 0px;
1934
1888
  margin-bottom: calc(var(--spacing) * 2);
1935
- font-size: 0.75rem;
1889
+ font-size: 0.8125rem;
1936
1890
  --tw-font-weight: var(--font-weight-bold);
1937
1891
  font-weight: var(--font-weight-bold);
1938
1892
  --tw-tracking: var(--tracking-normal);
@@ -2117,13 +2071,6 @@
2117
2071
  --tw-tracking: -0.01em;
2118
2072
  letter-spacing: -0.01em;
2119
2073
  }
2120
- .mvb-flip__tally {
2121
- font-size: var(--text-sm);
2122
- line-height: var(--tw-leading, var(--text-sm--line-height));
2123
- color: var(--mvb-ink-3);
2124
- --tw-numeric-spacing: tabular-nums;
2125
- font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
2126
- }
2127
2074
  .mvb-meter {
2128
2075
  height: calc(var(--spacing) * 2);
2129
2076
  overflow: hidden;
@@ -238,10 +238,6 @@
238
238
 
239
239
  .mvb-searchbtn__txt { @apply flex-1 text-start text-sm; }
240
240
 
241
- .mvb-searchbtn__key {
242
- @apply rounded-[4px] border border-orbit bg-deck px-1.5 py-px font-sans text-[0.71875rem] font-semibold text-ink-3;
243
- }
244
-
245
241
  /* nav sheet on a phone */
246
242
 
247
243
  .mvb-sheet {
@@ -272,8 +268,11 @@
272
268
 
273
269
  .mvb-link { @apply text-comet underline-offset-[3px]; }
274
270
 
271
+ /* One key chip for the whole app: the search button, the filter, the palette
272
+ footer and the help modal all point at the same key. It sits on a raised and a
273
+ recessed surface in turn, so it carries its own border instead of a fill. */
275
274
  .mvb-kbd {
276
- @apply rounded-[5px] border border-orbit bg-deck-2 px-[7px] py-px font-sans text-[0.78125rem] text-ink-2;
275
+ @apply rounded-[5px] border border-orbit-hard px-[7px] py-px font-sans text-[0.8125rem] leading-[1.45] text-ink-3;
277
276
  }
278
277
 
279
278
  /* hero, only on the home page */
@@ -411,16 +410,10 @@
411
410
  .mvb-filter__in:focus { @apply outline-none; }
412
411
  .mvb-filter__in::placeholder { @apply text-ink-3; }
413
412
 
414
- .mvb-filter__key {
415
- @apply rounded-[5px] border border-orbit px-[7px] py-0.5 font-sans text-[0.75rem] text-ink-3;
416
- }
417
-
418
413
  .mvb-filter__meta {
419
414
  @apply mt-2.5 flex min-h-[22px] items-baseline gap-3.5 text-sm text-ink-3;
420
415
  }
421
416
 
422
- .mvb-tally { @apply text-danger; }
423
-
424
417
  /* ---------- page headings ---------- */
425
418
 
426
419
  .mvb-lead {
@@ -766,7 +759,7 @@
766
759
  .mvb-pal__in:focus { @apply outline-none; }
767
760
  .mvb-pal__in::placeholder { @apply text-ink-3; }
768
761
 
769
- .mvb-pal__count { @apply m-0 px-[18px] pt-2 text-[0.78125rem] text-ink-3; }
762
+ .mvb-pal__count { @apply m-0 px-[18px] pt-2 text-[0.8125rem] text-ink-3; }
770
763
 
771
764
  .mvb-pal__list {
772
765
  @apply m-0 mt-1.5 max-h-[min(60vh,360px)] overflow-y-auto px-2 pb-2;
@@ -787,16 +780,12 @@
787
780
  .mvb-pal__text { @apply grid min-w-0 flex-1 gap-px; }
788
781
  .mvb-pal__name { @apply text-[0.9375rem] font-semibold tracking-[-0.01em]; }
789
782
  .mvb-pal__note { @apply overflow-hidden text-[0.84375rem] text-ellipsis whitespace-nowrap text-ink-2; }
790
- .mvb-pal__cat { @apply whitespace-nowrap text-[0.78125rem] text-ink-3; }
783
+ .mvb-pal__cat { @apply whitespace-nowrap text-[0.8125rem] text-ink-3; }
791
784
 
792
785
  .mvb-pal__empty { @apply p-[18px] text-sm text-ink-3; }
793
786
 
794
787
  .mvb-pal__foot {
795
- @apply m-0 flex flex-wrap items-center gap-2.5 border-t border-orbit px-[18px] py-2.5 text-[0.78125rem] text-ink-3;
796
- }
797
-
798
- .mvb-pal__key {
799
- @apply rounded-[4px] border border-orbit bg-deck-2 px-1.5 py-px font-sans text-[0.6875rem] text-ink-2;
788
+ @apply m-0 flex flex-wrap items-center gap-2.5 border-t border-orbit px-[18px] py-2.5 text-[0.8125rem] text-ink-3;
800
789
  }
801
790
 
802
791
  /* ---------- help modal ---------- */
@@ -830,11 +819,7 @@
830
819
  .mvb-help__steps li::before {
831
820
  content: counter(mvb-step);
832
821
  counter-increment: mvb-step;
833
- @apply grid size-[22px] place-items-center rounded-full bg-nebula text-[0.75rem] font-bold tabular-nums text-comet;
834
- }
835
-
836
- .mvb-help__actions {
837
- @apply mt-5 flex flex-wrap justify-end gap-2;
822
+ @apply grid size-[22px] place-items-center rounded-full bg-nebula text-[0.8125rem] font-bold tabular-nums text-comet;
838
823
  }
839
824
 
840
825
  /* ---------- footer ---------- */
@@ -869,7 +854,7 @@
869
854
  @apply grid grid-cols-2 gap-x-[clamp(20px,4vw,40px)] gap-y-1.5;
870
855
  }
871
856
 
872
- .mvb-foot__nav h4 { @apply m-0 mb-2 text-[0.75rem] font-bold tracking-normal text-ink-3 normal-case; }
857
+ .mvb-foot__nav h4 { @apply m-0 mb-2 text-[0.8125rem] font-bold tracking-normal text-ink-3 normal-case; }
873
858
  .mvb-foot__nav ul { @apply grid gap-1.5; }
874
859
  .mvb-foot__nav a { @apply text-[0.90625rem] text-ink-2 no-underline; }
875
860
  .mvb-foot__nav a:hover { @apply text-comet; }
@@ -944,7 +929,6 @@
944
929
  }
945
930
 
946
931
  .mvb-flip__result { @apply text-[1.1875rem] font-bold tracking-[-0.01em]; }
947
- .mvb-flip__tally { @apply text-sm tabular-nums text-ink-3; }
948
932
 
949
933
  /* ---------- meter ---------- */
950
934
 
@@ -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.1.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);
@@ -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
  });
@@ -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
 
@@ -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.1.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",