mevebu 1.1.0 → 1.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.
@@ -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;
@@ -1444,6 +1417,12 @@
1444
1417
  border-inline-start-color: var(--mvb-danger);
1445
1418
  color: var(--mvb-danger);
1446
1419
  }
1420
+ .mvb-note--warn {
1421
+ border-inline-start-color: var(--mvb-star);
1422
+ --tw-font-weight: var(--font-weight-medium);
1423
+ font-weight: var(--font-weight-medium);
1424
+ color: var(--mvb-ink);
1425
+ }
1447
1426
  .mvb-note--good {
1448
1427
  border-inline-start-color: var(--mvb-aurora);
1449
1428
  color: var(--mvb-aurora);
@@ -1679,7 +1658,7 @@
1679
1658
  margin: 0px;
1680
1659
  padding-inline: 18px;
1681
1660
  padding-top: calc(var(--spacing) * 2);
1682
- font-size: 0.78125rem;
1661
+ font-size: 0.8125rem;
1683
1662
  color: var(--mvb-ink-3);
1684
1663
  }
1685
1664
  .mvb-pal__list {
@@ -1751,7 +1730,7 @@
1751
1730
  color: var(--mvb-ink-2);
1752
1731
  }
1753
1732
  .mvb-pal__cat {
1754
- font-size: 0.78125rem;
1733
+ font-size: 0.8125rem;
1755
1734
  white-space: nowrap;
1756
1735
  color: var(--mvb-ink-3);
1757
1736
  }
@@ -1772,21 +1751,9 @@
1772
1751
  border-color: var(--mvb-orbit);
1773
1752
  padding-inline: 18px;
1774
1753
  padding-block: calc(var(--spacing) * 2.5);
1775
- font-size: 0.78125rem;
1754
+ font-size: 0.8125rem;
1776
1755
  color: var(--mvb-ink-3);
1777
1756
  }
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
1757
  .mvb-veil--help {
1791
1758
  padding-top: clamp(60px, 10vh, 120px);
1792
1759
  }
@@ -1858,20 +1825,13 @@
1858
1825
  place-items: center;
1859
1826
  border-radius: calc(infinity * 1px);
1860
1827
  background-color: var(--mvb-nebula);
1861
- font-size: 0.75rem;
1828
+ font-size: 0.8125rem;
1862
1829
  --tw-font-weight: var(--font-weight-bold);
1863
1830
  font-weight: var(--font-weight-bold);
1864
1831
  color: var(--mvb-comet);
1865
1832
  --tw-numeric-spacing: tabular-nums;
1866
1833
  font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
1867
1834
  }
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
1835
  .mvb-foot {
1876
1836
  margin-top: clamp(56px, 8vw, 96px);
1877
1837
  border-top-style: var(--tw-border-style);
@@ -1932,7 +1892,7 @@
1932
1892
  .mvb-foot__nav h4 {
1933
1893
  margin: 0px;
1934
1894
  margin-bottom: calc(var(--spacing) * 2);
1935
- font-size: 0.75rem;
1895
+ font-size: 0.8125rem;
1936
1896
  --tw-font-weight: var(--font-weight-bold);
1937
1897
  font-weight: var(--font-weight-bold);
1938
1898
  --tw-tracking: var(--tracking-normal);
@@ -2117,13 +2077,6 @@
2117
2077
  --tw-tracking: -0.01em;
2118
2078
  letter-spacing: -0.01em;
2119
2079
  }
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
2080
  .mvb-meter {
2128
2081
  height: calc(var(--spacing) * 2);
2129
2082
  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 {
@@ -674,6 +667,13 @@
674
667
  @apply border-s-danger text-danger;
675
668
  }
676
669
 
670
+ /* A warning is a caution, not a failure: amber rule, text that stays readable
671
+ on both surfaces. Without this rule a warn notice fell back to the neutral
672
+ note, so a real warning looked quieter than the all clear. */
673
+ .mvb-note--warn {
674
+ @apply border-s-star font-medium text-ink;
675
+ }
676
+
677
677
  .mvb-note--good {
678
678
  @apply border-s-aurora text-aurora;
679
679
  }
@@ -766,7 +766,7 @@
766
766
  .mvb-pal__in:focus { @apply outline-none; }
767
767
  .mvb-pal__in::placeholder { @apply text-ink-3; }
768
768
 
769
- .mvb-pal__count { @apply m-0 px-[18px] pt-2 text-[0.78125rem] text-ink-3; }
769
+ .mvb-pal__count { @apply m-0 px-[18px] pt-2 text-[0.8125rem] text-ink-3; }
770
770
 
771
771
  .mvb-pal__list {
772
772
  @apply m-0 mt-1.5 max-h-[min(60vh,360px)] overflow-y-auto px-2 pb-2;
@@ -787,16 +787,12 @@
787
787
  .mvb-pal__text { @apply grid min-w-0 flex-1 gap-px; }
788
788
  .mvb-pal__name { @apply text-[0.9375rem] font-semibold tracking-[-0.01em]; }
789
789
  .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; }
790
+ .mvb-pal__cat { @apply whitespace-nowrap text-[0.8125rem] text-ink-3; }
791
791
 
792
792
  .mvb-pal__empty { @apply p-[18px] text-sm text-ink-3; }
793
793
 
794
794
  .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;
795
+ @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
796
  }
801
797
 
802
798
  /* ---------- help modal ---------- */
@@ -830,11 +826,7 @@
830
826
  .mvb-help__steps li::before {
831
827
  content: counter(mvb-step);
832
828
  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;
829
+ @apply grid size-[22px] place-items-center rounded-full bg-nebula text-[0.8125rem] font-bold tabular-nums text-comet;
838
830
  }
839
831
 
840
832
  /* ---------- footer ---------- */
@@ -869,7 +861,7 @@
869
861
  @apply grid grid-cols-2 gap-x-[clamp(20px,4vw,40px)] gap-y-1.5;
870
862
  }
871
863
 
872
- .mvb-foot__nav h4 { @apply m-0 mb-2 text-[0.75rem] font-bold tracking-normal text-ink-3 normal-case; }
864
+ .mvb-foot__nav h4 { @apply m-0 mb-2 text-[0.8125rem] font-bold tracking-normal text-ink-3 normal-case; }
873
865
  .mvb-foot__nav ul { @apply grid gap-1.5; }
874
866
  .mvb-foot__nav a { @apply text-[0.90625rem] text-ink-2 no-underline; }
875
867
  .mvb-foot__nav a:hover { @apply text-comet; }
@@ -944,7 +936,6 @@
944
936
  }
945
937
 
946
938
  .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
939
 
949
940
  /* ---------- meter ---------- */
950
941
 
@@ -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
 
@@ -514,8 +514,8 @@ export function downloadButton(getData, filename, labelText = "Download", mime =
514
514
  * Single choice chips. `items` is [{ value, label }]. Returns the wrapper with
515
515
  * a `.select(value)` method so the tool can read the current value.
516
516
  */
517
- export function chipRow(items, { value, onChange, label, size = "sm" } = {}) {
518
- const wrap = el("div", { class: `mvb-chips mvb-chips--${size}`, role: "group", "aria-label": label || "Options" });
517
+ export function chipRow(items, { value, onChange, label } = {}) {
518
+ const wrap = el("div", { class: "mvb-chips", role: "group", "aria-label": label || "Options" });
519
519
  let current = value;
520
520
  const paint = () => {
521
521
  for (const chip of wrap.children) {
@@ -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.3.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
  ],
@@ -131,8 +131,8 @@ export const TOOLS = [
131
131
  tutorial: {
132
132
  intro: "Hash text with the SHA family. Hashing happens in this tab.",
133
133
  steps: [
134
- "Type or paste text on the left.",
135
- "Pick an algorithm with the chips (1 to 512).",
134
+ "Type or paste text.",
135
+ "Pick an algorithm with the chips.",
136
136
  "The hash updates as you type. Press Copy hash to copy the result.",
137
137
  ],
138
138
  },
@@ -272,16 +272,16 @@ export const TOOLS = [
272
272
  name: "UUID Generator",
273
273
  icon: "fingerprint-pattern",
274
274
  note: "Generate UUIDs, one or a thousand.",
275
- about: "Version 4 UUIDs from the browser random source.",
275
+ about: "Versions 1, 4, 6 and 7, plus the Nil and Max sentinels.",
276
276
  tutorial: {
277
- intro: "Generate version 4 UUIDs. Copy as a list, JSON, or save as a file.",
277
+ intro: "Generate UUIDs and take them as a list, as JSON, or as a file.",
278
278
  steps: [
279
- "Set how many UUIDs to generate, between 1 and 1000.",
280
- "Toggle Uppercase if you want them in caps. The list updates as you change a setting.",
281
- "Use Copy all, Copy JSON, or Save .txt on the right.",
279
+ "Pick the version. v4 and v7 are random and v7 sorts by time. v1 and v6 carry a timestamp. Nil and Max are the two sentinels tests look for.",
280
+ "Set how many to generate, between 1 and 1000. Uppercase and No hyphens change the output only.",
281
+ "Use Copy all, Copy JSON, or Save .txt to take the list.",
282
282
  ],
283
283
  },
284
- keywords: "guid unique id v4 identifier",
284
+ keywords: "guid unique id v1 v4 v6 v7 nil max identifier",
285
285
  load: () => import("./tools/uuid-generator.js"),
286
286
  },
287
287
  {
@@ -294,8 +294,8 @@ export const TOOLS = [
294
294
  tutorial: {
295
295
  intro: "Convert a color between hex, rgb, and hsl. The swatch and contrast reading update live.",
296
296
  steps: [
297
- "Pick a color with the picker on the left, or type a hex, rgb, or hsl value.",
298
- "The other formats, the swatch, and a contrast reading appear on the right.",
297
+ "Pick a color with the picker, or type a hex, rgb, or hsl value.",
298
+ "The other formats, the swatch, and a contrast reading update as you type.",
299
299
  "Click any of the four readouts to copy that value.",
300
300
  ],
301
301
  },
@@ -315,7 +315,7 @@ export const TOOLS = [
315
315
  steps: [
316
316
  "Type or paste on either side. The other side updates as you type.",
317
317
  "Edit the Base64 side directly to decode. Invalid Base64 shows an error.",
318
- "Use Copy Base64 or Copy text on the right of each panel.",
318
+ "Use Copy Base64 or Copy text to take either side.",
319
319
  ],
320
320
  },
321
321
  keywords: "encode decode binary data uri atob btoa",
@@ -1,21 +1,40 @@
1
1
  // Text to Base64 and back, live in both directions. Emoji and other non-Latin
2
2
  // text survive the round trip because the bytes are UTF-8.
3
- import { bench, stack, panel, textArea, notice, copyButton, actions, toolcard } from "../core.js";
3
+ import { checkbox, bench, stack, panel, textArea, notice, copyButton, actions, toolcard } from "../core.js";
4
4
 
5
- const encode = (text) => btoa(String.fromCharCode(...new TextEncoder().encode(text)));
6
- const decode = (text) => new TextDecoder().decode(
7
- Uint8Array.from(atob(text.replace(/\s+/g, "")), (char) => char.charCodeAt(0))
8
- );
5
+ // btoa and atob take a binary string. A long input is converted in chunks,
6
+ // because spreading a whole document into String.fromCharCode overflows the
7
+ // call stack.
8
+ const CHUNK = 0x8000;
9
+
10
+ function encode(text, urlSafe) {
11
+ const bytes = new TextEncoder().encode(text);
12
+ let binary = "";
13
+ for (let at = 0; at < bytes.length; at += CHUNK) {
14
+ binary += String.fromCharCode(...bytes.subarray(at, at + CHUNK));
15
+ }
16
+ const base = btoa(binary);
17
+ return urlSafe ? base.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") : base;
18
+ }
19
+
20
+ function decode(text) {
21
+ // Both alphabets are accepted and the padding goes back on: the URL-safe form
22
+ // drops its padding, and a JWT segment turns up that way.
23
+ const base = text.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/");
24
+ const padded = base.padEnd(base.length + ((4 - (base.length % 4)) % 4), "=");
25
+ return new TextDecoder().decode(Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)));
26
+ }
9
27
 
10
28
  export function mount(slot) {
11
29
  const plain = textArea({ rows: 7, value: "hello mevebu", "aria-label": "Plain text" });
12
30
  const coded = textArea({ rows: 7, class: "mvb-in mvb-in--area mvb-in--mono", "aria-label": "Base64" });
31
+ const urlSafeBox = checkbox("URL-safe, no padding", { checked: false });
13
32
  const error = notice("error");
14
33
  let guard = false;
15
34
 
16
35
  function refresh() {
17
36
  try {
18
- coded.value = plain.value ? encode(plain.value) : "";
37
+ coded.value = plain.value ? encode(plain.value, urlSafeBox.input.checked) : "";
19
38
  error.write("");
20
39
  } catch {
21
40
  error.write("That text could not be encoded.");
@@ -42,10 +61,12 @@ export function mount(slot) {
42
61
  }
43
62
  });
44
63
 
64
+ urlSafeBox.input.addEventListener("change", refresh);
65
+
45
66
  const card = toolcard({
46
67
  body: bench(
47
68
  stack(panel("Text", plain)),
48
- stack(panel("Base64", coded), error)
69
+ stack(panel("Base64", coded), urlSafeBox, error)
49
70
  ),
50
71
  foot: actions(
51
72
  copyButton(() => plain.value, "Copy text"),
@@ -58,4 +79,4 @@ export function mount(slot) {
58
79
  return {};
59
80
  }
60
81
 
61
- export default { mount };
82
+ export default { mount };
@@ -69,7 +69,7 @@ export function mount(slot) {
69
69
 
70
70
  source.addEventListener("input", convert);
71
71
 
72
- const card = toolcard({ body: stack(panel("Text", source), panel("Select a case to copy it", list)) });
72
+ const card = toolcard({ body: stack(panel("Text", source), panel("Cases", list)) });
73
73
  slot.append(card.node);
74
74
  convert();
75
75
 
@@ -53,8 +53,7 @@ export function mount(slot) {
53
53
 
54
54
  if (days < 0) status.write(`Expired ${Math.abs(days)} days ago.`);
55
55
  else if (days === 0) status.write("Expires today.");
56
- else if (days <= threshold) status.write(`Expires in ${days} days, within the ${threshold} day warning window.`);
57
- else status.write(`Expires in ${days} days.`);
56
+ else if (days <= threshold) status.write(`Inside the ${threshold} day warning window.`);
58
57
 
59
58
  summary = `Expires ${expiry.toLocaleDateString()}, ${days} days left, ${state}.`;
60
59
  }
@@ -67,7 +66,6 @@ export function mount(slot) {
67
66
  stack(
68
67
  panel("Expiry date", dateField),
69
68
  panel("Warn within days", warnField),
70
- el("p", { class: "mvb-note" }, "The date stays in this tab."),
71
69
  error
72
70
  ),
73
71
  stack(stats, status)
@@ -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,14 +91,16 @@ 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
- function show(color, keepPicker) {
96
+ // `from` names the control the value came from. The text field is never
97
+ // rewritten while it is being typed into: "#abc" is valid shorthand one
98
+ // keystroke before it is valid six digit hex, so echoing the expanded form
99
+ // back would wipe out the rest of what was being typed.
100
+ function show(color, from) {
97
101
  const hex = hexOf(color);
98
- if (!keepPicker) {
99
- anyField.value = hex.toUpperCase();
100
- picker.value = hex;
101
- }
102
+ if (from !== "text") anyField.value = hex.toUpperCase();
103
+ picker.value = hex;
102
104
  hexField.querySelector("input").value = hex.toUpperCase();
103
105
  const { h, s, l } = hslOf(color);
104
106
  rgbField.querySelector("input").value = color.a < 1
@@ -113,18 +115,23 @@ export function mount(slot) {
113
115
  reading.textContent = `Contrast ${onWhite}:1 on white, ${onBlack}:1 on black.${Number(onWhite) >= 4.5 ? "" : " Low on white."}${Number(onBlack) >= 4.5 ? "" : " Low on black."}`;
114
116
  }
115
117
 
116
- function read(text, keepPicker) {
118
+ function read(text, from) {
119
+ // An empty field is not a mistake, it is someone clearing it to start over.
120
+ if (!text.trim()) {
121
+ error.write("");
122
+ return;
123
+ }
117
124
  const color = parse(text);
118
125
  if (!color) {
119
126
  error.write("That color was not recognized.");
120
127
  return;
121
128
  }
122
129
  error.write("");
123
- show(color, keepPicker);
130
+ show(color, from);
124
131
  }
125
132
 
126
- anyField.addEventListener("input", () => read(anyField.value, false));
127
- picker.addEventListener("input", () => read(picker.value, true));
133
+ anyField.addEventListener("input", () => read(anyField.value, "text"));
134
+ picker.addEventListener("input", () => read(picker.value, "picker"));
128
135
 
129
136
  const card = toolcard({
130
137
  body: bench(
@@ -138,7 +145,7 @@ export function mount(slot) {
138
145
  ),
139
146
  });
140
147
  slot.append(card.node);
141
- read("#F5B942", false);
148
+ read("#F5B942", "init");
142
149
 
143
150
  return {};
144
151
  }
@@ -55,8 +55,8 @@ export function mount(slot) {
55
55
  body: bench(
56
56
  stack(
57
57
  panel("Values", source),
58
- panel("Mask", kinds),
59
- el("p", { class: "mvb-note" }, "Masking runs in this tab. Check the result before sharing.")
58
+ panel("What to mask", kinds),
59
+ el("p", { class: "mvb-note" }, "Pattern matching can miss a value, so check the result before sharing.")
60
60
  ),
61
61
  stack(panel("Masked", result))
62
62
  ),
@@ -46,6 +46,15 @@ export function mount(slot) {
46
46
  return Math.max(1, Math.min(200, parseInt(countField.value || "1", 10) || 1));
47
47
  }
48
48
 
49
+ /** The same rows as the table, tab separated. */
50
+ function tsv() {
51
+ const columns = Object.keys(rows[0] ?? {});
52
+ return [
53
+ columns.join("\t"),
54
+ ...rows.map((row) => columns.map((key) => String(row[key])).join("\t")),
55
+ ].join("\n");
56
+ }
57
+
49
58
  function render() {
50
59
  const total = totalCount();
51
60
  rows = Array.from({ length: total }, (_, i) => person(i + 1));
@@ -83,8 +92,11 @@ export function mount(slot) {
83
92
  countField.value = String(totalCount());
84
93
  });
85
94
  copyButton.addEventListener("click", async () => {
86
- const text = format === "table" ? JSON.stringify(rows) : text.textContent;
87
- toast((await copyText(text)) ? "Copied" : "Copy failed");
95
+ // In table mode the visible table is what gets copied, as tab separated text
96
+ // so it lands in a spreadsheet as a table. The local name here used to shadow
97
+ // the readout above, which threw before the copy ever ran.
98
+ const payload = format === "table" ? tsv() : text.textContent;
99
+ toast((await copyText(payload)) ? "Copied" : "Copy failed");
88
100
  });
89
101
  saveButton.addEventListener("click", () => {
90
102
  if (format === "csv") download("test-data.csv", text.textContent, "text/csv");
@@ -94,7 +106,7 @@ export function mount(slot) {
94
106
  const card = toolcard({
95
107
  body: bench(
96
108
  stack(panel("Format", chips), panel("Records", countField)),
97
- el("div", { class: "mvb-scroll" }, text, table)
109
+ el("div", { class: "mvb-stack" }, text, table)
98
110
  ),
99
111
  foot: actions(copyButton, saveButton, againButton),
100
112
  });
@@ -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
  });
@@ -1,6 +1,6 @@
1
1
  // Placeholder text by the word, the sentence or the paragraph. The classic
2
2
  // opening is a checkbox, not a lock-in, and regeneration never clears the copy.
3
- import { toast, copyText, chipRow, numberInput, checkbox, button, bench, stack, panel, textArea, readPin, writePin, actions, toolcard } from "../core.js";
3
+ import { toast, copyText, chipRow, numberInput, checkbox, output, button, bench, stack, panel, readPin, writePin, actions, toolcard } from "../core.js";
4
4
 
5
5
  const WORDS = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in reprehenderit voluptate velit esse cillum eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum".split(" ");
6
6
 
@@ -42,7 +42,9 @@ export function mount(slot) {
42
42
  );
43
43
  const amount = numberInput({ value: saved.amount ?? "3", min: "1", max: "200", "aria-label": "How many" });
44
44
  const classic = checkbox("Start with the classic opening", { checked: saved.classic !== false });
45
- const result = textArea({ rows: 12, readonly: true, class: "mvb-in mvb-in--area", "aria-label": "Generated text" });
45
+ // Generated text is read, not typed, so it sits on the recessed surface.
46
+ const result = output({ tag: "textarea", rows: 12 });
47
+ result.setAttribute("aria-label", "Generated text");
46
48
 
47
49
  function totalCount() {
48
50
  return Math.max(1, Math.min(200, parseInt(amount.value || "1", 10) || 1));
@@ -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();
@@ -1,10 +1,12 @@
1
1
  // Percent-encoding for a single value, following encodeURIComponent. The
2
2
  // swap button moves the result back into the input for a second round.
3
- import { bench, stack, panel, textArea, notice, chipRow, button, copyButton, actions, toolcard } from "../core.js";
3
+ import { bench, stack, panel, textArea, output, notice, chipRow, button, copyButton, actions, toolcard } from "../core.js";
4
4
 
5
5
  export function mount(slot) {
6
6
  const source = textArea({ rows: 5, value: "q=hello world&lang=id", "aria-label": "Input" });
7
- const result = textArea({ rows: 5, readonly: true, class: "mvb-in mvb-in--area mvb-in--mono", "aria-label": "Result" });
7
+ // The encoded text is read, not typed, so it sits on the recessed surface.
8
+ const result = output({ tag: "textarea", rows: 5 });
9
+ result.setAttribute("aria-label", "Result");
8
10
  const error = notice("error");
9
11
  let mode = "encode";
10
12
 
@@ -82,8 +82,7 @@ export function mount(slot) {
82
82
  body: bench(
83
83
  stack(
84
84
  panel("URL", source),
85
- error,
86
- el("p", { class: "mvb-note" }, "The link is read in this tab. Nothing is opened or requested.")
85
+ error
87
86
  ),
88
87
  stack(kv, panel("Checks", flags))
89
88
  ),
@@ -1,34 +1,129 @@
1
- // Version 4 UUIDs. Generates a list, copies the whole list, or copies JSON.
2
- import { el, toast, copyText, download, numberInput, checkbox, button, bench, stack, panel, output, readPin, writePin, actions, toolcard } from "../core.js";
1
+ // UUIDs in the versions that reach a schema: v4 and v7 are random, v7 sorts by
2
+ // time, v1 and v6 are the legacy timestamp pair, and Nil and Max are the two
3
+ // sentinels a test suite looks for.
4
+ import { el, toast, copyText, download, numberInput, checkbox, chipRow, button, bench, stack, panel, output, readPin, writePin, actions, toolcard } from "../core.js";
3
5
 
4
- function uuid() {
6
+ // 100 ns ticks between 1582-10-15 and 1970-01-01, the epoch a v1 stamp counts from.
7
+ const GREGORIAN_OFFSET = 122192928000000000n;
8
+
9
+ const VERSIONS = [
10
+ { value: "v4", label: "v4" },
11
+ { value: "v7", label: "v7" },
12
+ { value: "v1", label: "v1" },
13
+ { value: "v6", label: "v6" },
14
+ { value: "nil", label: "Nil" },
15
+ { value: "max", label: "Max" },
16
+ ];
17
+
18
+ const toHex = (bytes) => [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
19
+ const group = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
20
+ const randomHex = (bytes) => toHex(crypto.getRandomValues(new Uint8Array(bytes)));
21
+
22
+ function v4() {
5
23
  if (crypto.randomUUID) return crypto.randomUUID();
6
24
  const bytes = crypto.getRandomValues(new Uint8Array(16));
7
25
  bytes[6] = (bytes[6] & 0x0f) | 0x40;
8
26
  bytes[8] = (bytes[8] & 0x3f) | 0x80;
9
- const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0"));
10
- return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
27
+ return group(toHex(bytes));
28
+ }
29
+
30
+ let lastStamp = 0;
31
+ let counter = 0;
32
+
33
+ /**
34
+ * v7 puts a 48 bit millisecond stamp in front of random bits, so string order
35
+ * matches time order. A generated batch lands several UUIDs in the same
36
+ * millisecond, where the random tail alone would shuffle them, so the 12 bit
37
+ * field behind the stamp steps like a counter for the rest of that millisecond.
38
+ * It starts from a random value each millisecond to keep the tail unguessable.
39
+ */
40
+ function v7() {
41
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
42
+ // A clock that steps backwards would break the order as well, so never step back.
43
+ let ms = Math.max(Date.now(), lastStamp);
44
+ counter = counter + 1;
45
+ if (ms !== lastStamp || counter > 0x0fff) {
46
+ // A new millisecond, or more than 4095 in one: start over, and move the stamp
47
+ // on rather than hand out two UUIDs with the same timestamp and tail.
48
+ if (ms === lastStamp) ms += 1;
49
+ counter = (bytes[6] << 4) | bytes[7];
50
+ }
51
+ lastStamp = ms;
52
+ for (let at = 5; at >= 0; at--) {
53
+ bytes[at] = ms % 256;
54
+ ms = Math.floor(ms / 256);
55
+ }
56
+ bytes[6] = 0x70 | (counter >> 8);
57
+ bytes[7] = counter & 0xff;
58
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
59
+ return group(toHex(bytes));
60
+ }
61
+
62
+ /** The 60 bit timestamp v1 and v6 store, as 15 hex characters, most significant first. */
63
+ const stamp = () => (BigInt(Date.now()) * 10000n + GREGORIAN_OFFSET).toString(16).padStart(15, "0");
64
+
65
+ /** A node id nobody owns, so a generated v1 cannot point at real hardware. */
66
+ const nodeHex = () => (parseInt(randomHex(1), 16) | 0x01).toString(16).padStart(2, "0") + randomHex(5);
67
+
68
+ const sequenceHex = () => ((parseInt(randomHex(2), 16) & 0x3fff) | 0x8000).toString(16).padStart(4, "0");
69
+
70
+ /** v1 and v6 share every field; only the order of the timestamp differs. */
71
+ function timed(version) {
72
+ const ticks = stamp();
73
+ const sequence = sequenceHex();
74
+ const node = nodeHex();
75
+ // v1 stores the stamp low, mid, high, which is why it never sorts. v6 puts the
76
+ // high half first so the string order matches the time order.
77
+ return version === 1
78
+ ? `${ticks.slice(7)}-${ticks.slice(3, 7)}-1${ticks.slice(0, 3)}-${sequence}-${node}`
79
+ : `${ticks.slice(0, 8)}-${ticks.slice(8, 12)}-6${ticks.slice(12)}-${sequence}-${node}`;
80
+ }
81
+
82
+ function make(version) {
83
+ if (version === "nil") return "00000000-0000-0000-0000-000000000000";
84
+ if (version === "max") return "ffffffff-ffff-ffff-ffff-ffffffffffff";
85
+ if (version === "v7") return v7();
86
+ if (version === "v1") return timed(1);
87
+ if (version === "v6") return timed(6);
88
+ return v4();
11
89
  }
12
90
 
13
91
  export function mount(slot) {
14
92
  const PIN = "uuid-generator";
15
- const saved = readPin(PIN, { count: "5", caps: false });
93
+ const saved = readPin(PIN, { count: "5", caps: false, noHyphens: false, version: "v4" });
16
94
 
17
95
  const countField = numberInput({ value: saved.count ?? "5", min: "1", max: "1000", "aria-label": "How many" });
18
96
  const capsBox = checkbox("Uppercase", { checked: Boolean(saved.caps) });
97
+ const hyphensBox = checkbox("No hyphens", { checked: saved.noHyphens === true });
19
98
  const result = output({ rows: 8 });
99
+ let version = VERSIONS.some((item) => item.value === saved.version) ? saved.version : "v4";
20
100
  let current = [];
21
101
 
102
+ const versions = chipRow(VERSIONS, {
103
+ value: version,
104
+ label: "Version",
105
+ onChange: (value) => {
106
+ version = value;
107
+ generate();
108
+ },
109
+ });
110
+
22
111
  function totalCount() {
23
112
  return Math.max(1, Math.min(1000, parseInt(countField.value || "1", 10) || 1));
24
113
  }
25
114
 
26
115
  function generate() {
27
116
  const total = totalCount();
28
- current = Array.from({ length: total }, uuid);
117
+ current = Array.from({ length: total }, () => make(version));
29
118
  if (capsBox.input.checked) current = current.map((value) => value.toUpperCase());
119
+ if (hyphensBox.input.checked) current = current.map((value) => value.replace(/-/g, ""));
30
120
  result.write(current.join("\n"));
31
- writePin(PIN, { count: String(total), caps: capsBox.input.checked });
121
+ writePin(PIN, {
122
+ count: String(total),
123
+ caps: capsBox.input.checked,
124
+ noHyphens: hyphensBox.input.checked,
125
+ version,
126
+ });
32
127
  }
33
128
 
34
129
  const generateButton = button("Generate", { icon: "refresh-cw", kind: "fill" });
@@ -44,9 +139,10 @@ export function mount(slot) {
44
139
  countField.value = String(totalCount());
45
140
  });
46
141
  capsBox.input.addEventListener("change", generate);
142
+ hyphensBox.input.addEventListener("change", generate);
47
143
  copyButton.addEventListener("click", async () => {
48
144
  if (!current.length) return toast("Generate first, then copy");
49
- toast((await copyText(result.textContent)) ? `${current.length} copied` : "Copy failed");
145
+ toast((await copyText(current.join("\n"))) ? `${current.length} copied` : "Copy failed");
50
146
  });
51
147
  jsonButton.addEventListener("click", async () => {
52
148
  if (!current.length) return toast("Generate first, then copy");
@@ -55,13 +151,13 @@ export function mount(slot) {
55
151
  const saveButton = button("Save .txt", { icon: "download" });
56
152
  saveButton.addEventListener("click", () => {
57
153
  if (!current.length) return toast("Generate first");
58
- download("uuids.txt", result.textContent);
154
+ download("uuids.txt", current.join("\n"));
59
155
  });
60
156
 
61
157
  const card = toolcard({
62
158
  body: bench(
63
- stack(panel("How many", countField), capsBox),
64
- el("div", { class: "mvb-scroll" }, result)
159
+ stack(panel("Version", versions), panel("How many", countField), el("div", { class: "mvb-chips" }, capsBox, hyphensBox)),
160
+ result
65
161
  ),
66
162
  foot: actions(copyButton, jsonButton, saveButton, generateButton),
67
163
  });
@@ -71,4 +167,4 @@ export function mount(slot) {
71
167
  return {};
72
168
  }
73
169
 
74
- export default { mount };
170
+ export default { mount };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",