mevebu 1.6.0 → 1.7.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.
@@ -631,6 +631,9 @@
631
631
  .grid-cols-1 {
632
632
  grid-template-columns: repeat(1, minmax(0, 1fr));
633
633
  }
634
+ .grid-cols-2 {
635
+ grid-template-columns: repeat(2, minmax(0, 1fr));
636
+ }
634
637
  .grid-cols-\[minmax\(0\,1fr\)_auto\] {
635
638
  grid-template-columns: minmax(0,1fr) auto;
636
639
  }
@@ -824,6 +827,9 @@
824
827
  --tw-border-style: dashed;
825
828
  border-style: dashed;
826
829
  }
830
+ .border-danger {
831
+ border-color: var(--danger);
832
+ }
827
833
  .border-jade {
828
834
  border-color: var(--jade);
829
835
  }
@@ -851,6 +857,9 @@
851
857
  .bg-black {
852
858
  background-color: var(--color-black);
853
859
  }
860
+ .bg-danger {
861
+ background-color: var(--danger);
862
+ }
854
863
  .bg-deck {
855
864
  background-color: var(--deck);
856
865
  }
@@ -1229,6 +1238,9 @@
1229
1238
  .text-jade {
1230
1239
  color: var(--jade);
1231
1240
  }
1241
+ .text-on-danger {
1242
+ color: var(--on-danger);
1243
+ }
1232
1244
  .text-on-jade {
1233
1245
  color: var(--on-jade);
1234
1246
  }
@@ -1419,6 +1431,9 @@
1419
1431
  border-color: var(--jade);
1420
1432
  }
1421
1433
  @media (hover: hover) {
1434
+ .hover\:border-danger-deep:hover {
1435
+ border-color: var(--danger-deep);
1436
+ }
1422
1437
  .hover\:border-jade:hover {
1423
1438
  border-color: var(--jade);
1424
1439
  }
@@ -1428,6 +1443,9 @@
1428
1443
  .hover\:border-orbit-hard:hover {
1429
1444
  border-color: var(--orbit-hard);
1430
1445
  }
1446
+ .hover\:bg-danger-deep:hover {
1447
+ background-color: var(--danger-deep);
1448
+ }
1431
1449
  .hover\:bg-deck:hover {
1432
1450
  background-color: var(--deck);
1433
1451
  }
@@ -1651,6 +1669,11 @@
1651
1669
  grid-template-columns: repeat(3, minmax(0, 1fr));
1652
1670
  }
1653
1671
  }
1672
+ @media (width >= 80rem) {
1673
+ .min-\[80rem\]\:grid-cols-2 {
1674
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1675
+ }
1676
+ }
1654
1677
  .\[\&_a\]\:text-jade a {
1655
1678
  color: var(--jade);
1656
1679
  }
@@ -1994,6 +2017,8 @@
1994
2017
  --on-jade: #ffffff;
1995
2018
  --jade-soft: #e0f0e7;
1996
2019
  --danger: #b0231d;
2020
+ --danger-deep: #8e1b16;
2021
+ --on-danger: #ffffff;
1997
2022
  --star: #b37700;
1998
2023
  --track: #bfcac1;
1999
2024
  --scrim: rgb(8 14 11 / 0.5);
@@ -2039,8 +2064,10 @@
2039
2064
  --on-jade: #04120b;
2040
2065
  --jade-soft: #10291f;
2041
2066
  --danger: #ff8a80;
2067
+ --danger-deep: #ffb3ab;
2068
+ --on-danger: #2a0b08;
2042
2069
  --star: #ffcf4d;
2043
- --track: #2b4033;
2070
+ --track: #3f5c49;
2044
2071
  --scrim: rgb(2 6 4 / 0.68);
2045
2072
  --shadow: 0 22px 50px -20px rgb(0 0 0 / 0.7);
2046
2073
  --caret: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2385978a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
@@ -2075,7 +2102,10 @@
2075
2102
  border-radius: 6px;
2076
2103
  }
2077
2104
  ::selection {
2078
- background: var(--jade-soft);
2105
+ background: var(--jade);
2106
+ @supports (color: color-mix(in lab, red, red)) {
2107
+ background: color-mix(in srgb, var(--jade) 34%, transparent);
2108
+ }
2079
2109
  }
2080
2110
  @media (prefers-reduced-motion: reduce) {
2081
2111
  *, *::before, *::after {
@@ -50,6 +50,8 @@
50
50
  --on-jade: #ffffff;
51
51
  --jade-soft: #e0f0e7;
52
52
  --danger: #b0231d;
53
+ --danger-deep: #8e1b16;
54
+ --on-danger: #ffffff;
53
55
  --star: #b37700;
54
56
  --track: #bfcac1;
55
57
  --scrim: rgb(8 14 11 / 0.5);
@@ -106,8 +108,10 @@
106
108
  --on-jade: #04120b;
107
109
  --jade-soft: #10291f;
108
110
  --danger: #ff8a80;
111
+ --danger-deep: #ffb3ab;
112
+ --on-danger: #2a0b08;
109
113
  --star: #ffcf4d;
110
- --track: #2b4033;
114
+ --track: #3f5c49;
111
115
  --scrim: rgb(2 6 4 / 0.68);
112
116
  --shadow: 0 22px 50px -20px rgb(0 0 0 / 0.7);
113
117
  --caret: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2385978a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
@@ -136,6 +140,8 @@
136
140
  --color-on-jade: var(--on-jade);
137
141
  --color-jade-soft: var(--jade-soft);
138
142
  --color-danger: var(--danger);
143
+ --color-danger-deep: var(--danger-deep);
144
+ --color-on-danger: var(--on-danger);
139
145
  --color-star: var(--star);
140
146
  --color-track: var(--track);
141
147
 
@@ -201,7 +207,13 @@
201
207
  border-radius: 6px;
202
208
  }
203
209
 
204
- ::selection { background: var(--jade-soft); }
210
+ /* A selection has to read on both grounds. The browser default nearly vanishes
211
+ on the dark deck, and `--jade-soft` is darker still there, so the highlight
212
+ is the accent mixed down instead, which stays visible over a field, a
213
+ readout and the page alike. */
214
+ ::selection {
215
+ background: color-mix(in srgb, var(--jade) 34%, transparent);
216
+ }
205
217
 
206
218
  @media (prefers-reduced-motion: reduce) {
207
219
  *, *::before, *::after {
@@ -616,6 +616,7 @@ const BTN_KIND = {
616
616
  fill: "border-jade bg-jade px-4 text-on-jade hover:border-jade-deep hover:bg-jade-deep",
617
617
  plain: "border-orbit bg-deck px-4 text-ink hover:border-orbit-hard hover:bg-deck-2",
618
618
  quiet: "border-transparent bg-transparent px-2.5 text-ink-2 hover:bg-deck-2 hover:text-ink",
619
+ danger: "border-danger bg-danger px-4 text-on-danger hover:border-danger-deep hover:bg-danger-deep",
619
620
  };
620
621
 
621
622
  export function button(labelText, { icon: iconName, kind = "plain", class: extra, tag = "button", ...rest } = {}) {
@@ -623,7 +624,9 @@ export function button(labelText, { icon: iconName, kind = "plain", class: extra
623
624
  tag,
624
625
  { class: withExtra(`${BTN} ${BTN_KIND[kind] ?? BTN_KIND.plain}`, extra), ...(tag === "button" ? { type: "button" } : null), ...rest },
625
626
  iconName ? icon(iconName) : null,
626
- el("span", {}, labelText)
627
+ // An icon-only button skips the label span: an empty one still spends the
628
+ // row's gap, which pushes the icon off centre.
629
+ labelText ? el("span", {}, labelText) : null
627
630
  );
628
631
  }
629
632
 
@@ -681,10 +684,14 @@ export function chipRow(items, { value, onChange, label } = {}) {
681
684
  return wrap;
682
685
  }
683
686
 
684
- /** Independent toggles. Returns the wrapper with `.values()` reading the boxes. */
685
- export function toggleRow(items) {
687
+ /**
688
+ * Independent toggles. Returns the wrapper with `.values()` reading the boxes.
689
+ * `columns: 2` lays them out on an even grid, so a set of related boxes lines up
690
+ * instead of ragging across the row.
691
+ */
692
+ export function toggleRow(items, { columns } = {}) {
686
693
  const boxes = new Map();
687
- const wrap = el("div", { class: "flex flex-wrap gap-1.5" });
694
+ const wrap = el("div", { class: columns === 2 ? "grid grid-cols-2 gap-1.5" : "flex flex-wrap gap-1.5" });
688
695
  for (const item of items) {
689
696
  const node = checkbox(item.label, { checked: item.checked !== false });
690
697
  boxes.set(item.value, node.input);
@@ -743,7 +750,7 @@ export function blank(iconName, text) {
743
750
  // ---------- version ----------
744
751
 
745
752
  // Kept in one place. The CLI reads the same value from package.json.
746
- export const VERSION = "1.6.0";
753
+ export const VERSION = "1.7.0";
747
754
  export const PACKAGE_NAME = "mevebu";
748
755
  export const REPO_SLUG = "mevebu/mevebu";
749
756
 
@@ -34,7 +34,7 @@ export function mount(slot) {
34
34
  JSON.stringify(cards.map(({ question: q, answer: a }) => ({ question: q, answer: a })), null, 2);
35
35
 
36
36
  function removeButton(card) {
37
- const node = button("", { icon: "trash", "aria-label": `Remove ${card.question}` });
37
+ const node = button("", { icon: "trash", kind: "danger", "aria-label": `Remove ${card.question}` });
38
38
  node.addEventListener("click", () => {
39
39
  cards = cards.filter((entry) => entry.id !== card.id);
40
40
  if (index >= cards.length) index = Math.max(0, cards.length - 1);
@@ -52,10 +52,22 @@ export function mount(slot) {
52
52
  const card = cards[index];
53
53
  faceLabel.textContent = flipped ? "Answer" : "Question";
54
54
  face.textContent = flipped ? card.answer : card.question;
55
- listWrap.replaceChildren(el("table", { class: "w-full border-collapse font-mono text-sm [&_th]:wrap-anywhere [&_td]:wrap-anywhere [&_th]:border-b [&_td]:border-b [&_th]:border-orbit [&_td]:border-orbit [&_th]:px-2.5 [&_td]:px-2.5 [&_th]:py-2 [&_td]:py-2 [&_th]:text-start [&_td]:text-start [&_th]:font-sans [&_th]:text-2xs [&_th]:font-semibold [&_th]:text-ink-2 [&_tbody>tr:last-child>th]:border-b-0 [&_tbody>tr:last-child>td]:border-b-0" },
56
- el("tbody", {}, ...cards.map((entry, i) => el("tr", {},
57
- el("th", {}, `${i + 1}. ${entry.question}`),
58
- el("td", {}, removeButton(entry)))))));
55
+ // A row per card, not a table: the question takes the width it needs and
56
+ // the remove button stays on the centre line of every row.
57
+ listWrap.replaceChildren(
58
+ el(
59
+ "ol",
60
+ { class: "m-0 grid list-none gap-1.5 p-0" },
61
+ ...cards.map((entry, i) =>
62
+ el(
63
+ "li",
64
+ { class: "flex items-center gap-3 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-2.5" },
65
+ el("span", { class: "min-w-0 flex-1 wrap-anywhere font-mono text-sm" }, `${i + 1}. ${entry.question}`),
66
+ removeButton(entry)
67
+ )
68
+ )
69
+ )
70
+ );
59
71
  }
60
72
  stats.replaceChildren(tile("Cards", cards.length), tile("Card", cards.length ? `${index + 1} / ${cards.length}` : "-"));
61
73
  flipButton.disabled = !cards.length;
@@ -1,9 +1,14 @@
1
- // Signs a JSON Web Token with HS256. Checks that the JSON parses and shows a
1
+ // Signs a JSON Web Token with HMAC. Checks that the JSON parses and shows a
2
2
  // readable decode of the payload, so a typo surfaces before signing.
3
- import { el, icon, bench, stack, panel, textArea, textInput, notice, copyButton, output, debounce, actions, toolcard } from "../core.js";
3
+ import { el, icon, bench, stack, panel, textArea, textInput, selectInput, notice, copyButton, output, button, debounce, actions, toolcard } from "../core.js";
4
4
 
5
5
  const encoder = new TextEncoder();
6
6
 
7
+ // What can actually be signed here. The select is built from this, so the list
8
+ // and the signing call can never drift apart.
9
+ const ALGS = { HS256: "SHA-256", HS384: "SHA-384", HS512: "SHA-512" };
10
+ const TYPES = ["JWT", "at+jwt"];
11
+
7
12
  // Only claims that carry a value the JSON does not show by itself: a Unix
8
13
  // timestamp means nothing to read, the date behind it does.
9
14
  const TIME_CLAIMS = [["iat", "Issued at"], ["nbf", "Not before"], ["exp", "Expires"]];
@@ -18,16 +23,14 @@ const toUrlString = (text) => toUrl(encoder.encode(text));
18
23
  export function mount(slot) {
19
24
  const now = Math.floor(Date.now() / 1000);
20
25
 
21
- const headerField = textArea({ rows: 3, value: '{\n "alg": "HS256",\n "typ": "JWT"\n}' });
26
+ const headerField = textArea({ rows: 6, value: '{\n "alg": "HS256",\n "typ": "JWT"\n}' });
22
27
  const payloadField = textArea({
23
- rows: 7,
28
+ rows: 6,
24
29
  value: `{\n "sub": "1234567890",\n "name": "Ada",\n "admin": true,\n "iat": ${now}\n}`});
25
30
  const secretField = textInput({ type: "password", value: "your-256-bit-secret", autocomplete: "new-password" });
26
- const showButton = el(
27
- "button",
28
- { class: "inline-flex size-[var(--ctl)] cursor-pointer items-center justify-center rounded-r1 border border-transparent bg-transparent text-ink-2 transition-colors duration-150 hover:bg-deck-2 hover:text-ink focus-visible:border-jade", type: "button", "aria-label": "Show secret", "aria-pressed": "false" },
29
- icon("eye")
30
- );
31
+ const algField = selectInput(Object.keys(ALGS), { value: "HS256", "aria-label": "Algorithm" });
32
+ const typField = selectInput(TYPES, { value: "JWT", "aria-label": "Token type" });
33
+ const showButton = button("", { icon: "eye", kind: "plain", "aria-label": "Show secret", "aria-pressed": "false" });
31
34
  const error = notice("error");
32
35
  const tokenOut = output({ tag: "textarea", rows: 4 });
33
36
  const decoded = output({ rows: 4 });
@@ -43,6 +46,33 @@ export function mount(slot) {
43
46
  showButton.setAttribute("aria-pressed", String(!visible));
44
47
  });
45
48
 
49
+ /** The header as data, or null while it is mid edit. */
50
+ const readHeader = () => {
51
+ try {
52
+ const parsed = JSON.parse(headerField.value);
53
+ return parsed && typeof parsed === "object" ? parsed : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ };
58
+
59
+ /** A header typed by hand still moves the two selects when it names a known value. */
60
+ function followHeader() {
61
+ const header = readHeader();
62
+ if (!header) return;
63
+ if (ALGS[header.alg]) algField.value = header.alg;
64
+ if (TYPES.includes(header.typ)) typField.value = header.typ;
65
+ }
66
+
67
+ /** A select writes back into the header, which stays the one source of truth. */
68
+ function writeHeader() {
69
+ const header = readHeader() ?? {};
70
+ header.alg = algField.value;
71
+ header.typ = typField.value;
72
+ headerField.value = JSON.stringify(header, null, 2);
73
+ sign();
74
+ }
75
+
46
76
  async function sign() {
47
77
  error.write("");
48
78
  let header;
@@ -54,8 +84,9 @@ export function mount(slot) {
54
84
  error.write(`That JSON does not parse. ${caught.message}`);
55
85
  return;
56
86
  }
57
- if (header.alg && header.alg !== "HS256") {
58
- error.write("HS256 is the only algorithm here. Change alg to HS256.");
87
+ const hash = ALGS[header.alg];
88
+ if (!hash) {
89
+ error.write(`Only ${Object.keys(ALGS).join(", ")} can be signed here. Set alg to one of those.`);
59
90
  return;
60
91
  }
61
92
  let token = "";
@@ -63,7 +94,7 @@ export function mount(slot) {
63
94
  const first = toUrlString(JSON.stringify(header));
64
95
  const second = toUrlString(JSON.stringify(payload));
65
96
  const message = `${first}.${second}`;
66
- const key = await crypto.subtle.importKey("raw", encoder.encode(secretField.value), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
97
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secretField.value), { name: "HMAC", hash }, false, ["sign"]);
67
98
  const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
68
99
  token = `${message}.${toUrl(new Uint8Array(signature))}`;
69
100
  } catch (caught) {
@@ -84,19 +115,30 @@ export function mount(slot) {
84
115
  // Live signing. The textareas are the only slow field: invalid JSON while a
85
116
  // half-typed brace is on screen is normal, so the wait is longer than usual.
86
117
  const live = debounce(sign, 250);
87
- for (const field of [headerField, payloadField, secretField]) {
88
- field.addEventListener("input", live);
89
- }
118
+ headerField.addEventListener("input", () => { followHeader(); live(); });
119
+ for (const field of [payloadField, secretField]) field.addEventListener("input", live);
120
+ for (const field of [algField, typField]) field.addEventListener("change", writeHeader);
90
121
 
91
122
  const card = toolcard({
92
123
  body: bench(
93
124
  stack(
94
- panel("Header", headerField),
95
- panel("Payload", payloadField),
125
+ // The secret leads the column: it is the one field a visitor has to fill
126
+ // before the token means anything, and the two header choices are the
127
+ // adjustment after it.
96
128
  panel("Secret", el("div", { class: "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2" }, secretField, showButton)),
129
+ panel("Algorithm", algField),
130
+ panel("Type", typField),
97
131
  error
98
132
  ),
99
- stack(panel("Token", tokenOut), timePanel)
133
+ stack(
134
+ // Header and payload sit side by side once there is room for two columns,
135
+ // which is the pair a reader compares anyway.
136
+ el("div", { class: "grid gap-4 min-[80rem]:grid-cols-2" },
137
+ panel("Header", headerField),
138
+ panel("Payload", payloadField)),
139
+ panel("Token", tokenOut),
140
+ timePanel
141
+ )
100
142
  ),
101
143
  foot: actions(copyBtn)});
102
144
  slot.append(card.node);
@@ -105,4 +147,4 @@ export function mount(slot) {
105
147
  return {};
106
148
  }
107
149
 
108
- export default { mount };
150
+ export default { mount };
@@ -15,11 +15,12 @@ export function mount(slot) {
15
15
  lengthLabel.textContent = `Length: ${lengthField.value}`;
16
16
  }
17
17
 
18
+ // Two columns, so the four groups read as a block instead of ragging.
18
19
  const groups = toggleRow([
19
20
  { value: "lower", label: "a to z" },
20
21
  { value: "upper", label: "A to Z" },
21
22
  { value: "digits", label: "0 to 9" },
22
- { value: "symbols", label: "Symbols" }]);
23
+ { value: "symbols", label: "Symbols" }], { columns: 2 });
23
24
 
24
25
  const secret = textInput({ mono: true, readonly: true, "aria-label": "Password", placeholder: "Press Generate" });
25
26
  const meter = el("div", { class: "h-2 overflow-hidden rounded-full bg-deck-2", role: "img", "aria-label": "Strength" }, el("div", { class: "h-full w-0 rounded-full bg-orbit-hard transition-[width,background-color] duration-150 data-[level=weak]:bg-danger data-[level=ok]:bg-star data-[level=strong]:bg-jade" }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "1.6.0",
3
+ "version": "1.7.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",