mevebu 1.2.0 → 1.4.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.
Files changed (167) hide show
  1. package/mevebu/app/mevebu.js +260 -208
  2. package/mevebu/app/tailwind.css +1639 -2077
  3. package/mevebu/app/tailwind.src.css +155 -1120
  4. package/mevebu/index.html +2 -2
  5. package/mevebu/libs/js/core.js +236 -96
  6. package/mevebu/libs/js/registry.js +11 -11
  7. package/mevebu/libs/js/tools/abstract-checklist.js +5 -7
  8. package/mevebu/libs/js/tools/add-subtract-days.js +9 -11
  9. package/mevebu/libs/js/tools/age-calculator.js +7 -8
  10. package/mevebu/libs/js/tools/api-request-builder.js +3 -4
  11. package/mevebu/libs/js/tools/area-converter.js +7 -9
  12. package/mevebu/libs/js/tools/aspect-ratio.js +8 -9
  13. package/mevebu/libs/js/tools/average-calculator.js +4 -5
  14. package/mevebu/libs/js/tools/base64.js +31 -11
  15. package/mevebu/libs/js/tools/binary-calculator.js +6 -7
  16. package/mevebu/libs/js/tools/border-radius.js +7 -9
  17. package/mevebu/libs/js/tools/box-shadow.js +6 -7
  18. package/mevebu/libs/js/tools/break-even.js +4 -5
  19. package/mevebu/libs/js/tools/business-days.js +7 -8
  20. package/mevebu/libs/js/tools/canonical-tag.js +5 -6
  21. package/mevebu/libs/js/tools/caption-counter.js +5 -7
  22. package/mevebu/libs/js/tools/case-converter.js +11 -12
  23. package/mevebu/libs/js/tools/case-inspector.js +14 -16
  24. package/mevebu/libs/js/tools/cash-denomination.js +7 -8
  25. package/mevebu/libs/js/tools/cash-flow.js +7 -8
  26. package/mevebu/libs/js/tools/cert-expiry.js +6 -10
  27. package/mevebu/libs/js/tools/changelog-generator.js +4 -6
  28. package/mevebu/libs/js/tools/character-counter.js +10 -13
  29. package/mevebu/libs/js/tools/checklist.js +6 -7
  30. package/mevebu/libs/js/tools/citation-number-check.js +10 -13
  31. package/mevebu/libs/js/tools/coin-flip.js +9 -11
  32. package/mevebu/libs/js/tools/color-converter.js +23 -17
  33. package/mevebu/libs/js/tools/color-palette.js +6 -8
  34. package/mevebu/libs/js/tools/color-picker.js +4 -5
  35. package/mevebu/libs/js/tools/content-calendar.js +3 -4
  36. package/mevebu/libs/js/tools/contrast-checker.js +5 -6
  37. package/mevebu/libs/js/tools/cron-builder.js +4 -6
  38. package/mevebu/libs/js/tools/css-clamp.js +5 -6
  39. package/mevebu/libs/js/tools/css-formatter.js +4 -7
  40. package/mevebu/libs/js/tools/css-grid.js +6 -7
  41. package/mevebu/libs/js/tools/css-minifier.js +5 -8
  42. package/mevebu/libs/js/tools/csv-to-json.js +2 -3
  43. package/mevebu/libs/js/tools/csv-viewer.js +9 -11
  44. package/mevebu/libs/js/tools/currency-format.js +5 -7
  45. package/mevebu/libs/js/tools/daily-planner.js +9 -10
  46. package/mevebu/libs/js/tools/data-masker.js +7 -9
  47. package/mevebu/libs/js/tools/data-storage.js +7 -9
  48. package/mevebu/libs/js/tools/data-validator.js +9 -11
  49. package/mevebu/libs/js/tools/date-difference.js +6 -7
  50. package/mevebu/libs/js/tools/date-format.js +11 -13
  51. package/mevebu/libs/js/tools/delimiter-converter.js +3 -5
  52. package/mevebu/libs/js/tools/discount-calculator.js +4 -5
  53. package/mevebu/libs/js/tools/doi-resolver.js +4 -5
  54. package/mevebu/libs/js/tools/env-formatter.js +3 -6
  55. package/mevebu/libs/js/tools/equation-formatter.js +5 -9
  56. package/mevebu/libs/js/tools/expense-split.js +6 -7
  57. package/mevebu/libs/js/tools/fake-data.js +20 -13
  58. package/mevebu/libs/js/tools/find-replace.js +3 -5
  59. package/mevebu/libs/js/tools/flashcard-creator.js +9 -10
  60. package/mevebu/libs/js/tools/font-pairing.js +6 -8
  61. package/mevebu/libs/js/tools/fraction-calculator.js +10 -13
  62. package/mevebu/libs/js/tools/git-command-builder.js +11 -22
  63. package/mevebu/libs/js/tools/gpa-calculator.js +8 -10
  64. package/mevebu/libs/js/tools/grade-calculator.js +7 -8
  65. package/mevebu/libs/js/tools/gradient-generator.js +10 -12
  66. package/mevebu/libs/js/tools/habit-tracker.js +8 -9
  67. package/mevebu/libs/js/tools/hash-generator.js +3 -5
  68. package/mevebu/libs/js/tools/hashtag-formatter.js +6 -8
  69. package/mevebu/libs/js/tools/hmac-generator.js +3 -5
  70. package/mevebu/libs/js/tools/hpp-calculator.js +4 -5
  71. package/mevebu/libs/js/tools/html-entity.js +2 -3
  72. package/mevebu/libs/js/tools/html-formatter.js +3 -6
  73. package/mevebu/libs/js/tools/http-status.js +5 -7
  74. package/mevebu/libs/js/tools/inventory-quantity.js +7 -8
  75. package/mevebu/libs/js/tools/json-formatter.js +3 -6
  76. package/mevebu/libs/js/tools/json-minifier.js +5 -8
  77. package/mevebu/libs/js/tools/json-to-csv.js +3 -5
  78. package/mevebu/libs/js/tools/json-to-typescript.js +2 -5
  79. package/mevebu/libs/js/tools/json-validator.js +2 -3
  80. package/mevebu/libs/js/tools/jwt-decoder.js +6 -7
  81. package/mevebu/libs/js/tools/jwt-expiry.js +5 -6
  82. package/mevebu/libs/js/tools/jwt-generator.js +4 -6
  83. package/mevebu/libs/js/tools/length-converter.js +7 -9
  84. package/mevebu/libs/js/tools/license-reference.js +10 -13
  85. package/mevebu/libs/js/tools/line-counter.js +5 -7
  86. package/mevebu/libs/js/tools/list-difference.js +5 -7
  87. package/mevebu/libs/js/tools/list-merger.js +4 -5
  88. package/mevebu/libs/js/tools/locale-number.js +5 -8
  89. package/mevebu/libs/js/tools/lorem-ipsum.js +4 -2
  90. package/mevebu/libs/js/tools/markdown-preview.js +5 -8
  91. package/mevebu/libs/js/tools/markdown-table.js +5 -7
  92. package/mevebu/libs/js/tools/markup-calculator.js +4 -5
  93. package/mevebu/libs/js/tools/meeting-agenda.js +8 -9
  94. package/mevebu/libs/js/tools/meta-preview.js +12 -13
  95. package/mevebu/libs/js/tools/meta-tags.js +6 -7
  96. package/mevebu/libs/js/tools/mime-types.js +5 -7
  97. package/mevebu/libs/js/tools/name-picker.js +2 -3
  98. package/mevebu/libs/js/tools/number-base-converter.js +9 -12
  99. package/mevebu/libs/js/tools/password-generator.js +9 -12
  100. package/mevebu/libs/js/tools/password-strength.js +21 -25
  101. package/mevebu/libs/js/tools/percent-to-fraction.js +6 -7
  102. package/mevebu/libs/js/tools/percentage-calculator.js +6 -9
  103. package/mevebu/libs/js/tools/percentage-change.js +4 -5
  104. package/mevebu/libs/js/tools/permutation-combination.js +9 -12
  105. package/mevebu/libs/js/tools/prefix-suffix.js +2 -3
  106. package/mevebu/libs/js/tools/priority-matrix.js +6 -10
  107. package/mevebu/libs/js/tools/profit-margin.js +4 -5
  108. package/mevebu/libs/js/tools/qr-generator.js +3 -4
  109. package/mevebu/libs/js/tools/qr-scanner.js +11 -12
  110. package/mevebu/libs/js/tools/quadratic-solver.js +7 -8
  111. package/mevebu/libs/js/tools/query-builder.js +2 -3
  112. package/mevebu/libs/js/tools/quiz-formatter.js +4 -6
  113. package/mevebu/libs/js/tools/ratio-calculator.js +7 -8
  114. package/mevebu/libs/js/tools/readability.js +12 -15
  115. package/mevebu/libs/js/tools/reading-time.js +5 -7
  116. package/mevebu/libs/js/tools/reference-completeness.js +6 -8
  117. package/mevebu/libs/js/tools/regex-tester.js +7 -10
  118. package/mevebu/libs/js/tools/remove-duplicate-lines.js +2 -3
  119. package/mevebu/libs/js/tools/remove-extra-spaces.js +3 -5
  120. package/mevebu/libs/js/tools/robots-txt.js +3 -4
  121. package/mevebu/libs/js/tools/roman-numeral-converter.js +6 -8
  122. package/mevebu/libs/js/tools/sales-commission.js +8 -9
  123. package/mevebu/libs/js/tools/scientific-calculator.js +10 -14
  124. package/mevebu/libs/js/tools/scientific-notation.js +8 -9
  125. package/mevebu/libs/js/tools/script-planner.js +8 -10
  126. package/mevebu/libs/js/tools/selling-price.js +4 -5
  127. package/mevebu/libs/js/tools/semver-helper.js +3 -5
  128. package/mevebu/libs/js/tools/sentence-length.js +9 -10
  129. package/mevebu/libs/js/tools/sequence-analyzer.js +7 -8
  130. package/mevebu/libs/js/tools/sitemap-xml.js +4 -7
  131. package/mevebu/libs/js/tools/slug-converter.js +3 -5
  132. package/mevebu/libs/js/tools/slug-preview.js +6 -7
  133. package/mevebu/libs/js/tools/sort-lines.js +3 -5
  134. package/mevebu/libs/js/tools/speed-converter.js +7 -9
  135. package/mevebu/libs/js/tools/spin-wheel.js +19 -17
  136. package/mevebu/libs/js/tools/sql-formatter.js +5 -9
  137. package/mevebu/libs/js/tools/sql-minifier.js +5 -8
  138. package/mevebu/libs/js/tools/standard-deviation.js +6 -9
  139. package/mevebu/libs/js/tools/stopwatch.js +4 -6
  140. package/mevebu/libs/js/tools/study-schedule.js +8 -9
  141. package/mevebu/libs/js/tools/subtitle-formatter.js +10 -11
  142. package/mevebu/libs/js/tools/table-to-markdown.js +8 -12
  143. package/mevebu/libs/js/tools/tax-calculator.js +4 -5
  144. package/mevebu/libs/js/tools/temperature-converter.js +8 -11
  145. package/mevebu/libs/js/tools/text-diff.js +11 -7
  146. package/mevebu/libs/js/tools/text-direction.js +3 -5
  147. package/mevebu/libs/js/tools/text-reverser.js +3 -5
  148. package/mevebu/libs/js/tools/time-duration.js +8 -9
  149. package/mevebu/libs/js/tools/timestamp-converter.js +4 -6
  150. package/mevebu/libs/js/tools/timezone-converter.js +9 -13
  151. package/mevebu/libs/js/tools/title-length.js +6 -7
  152. package/mevebu/libs/js/tools/unicode-inspector.js +4 -5
  153. package/mevebu/libs/js/tools/url-encoder.js +4 -2
  154. package/mevebu/libs/js/tools/url-inspector.js +9 -11
  155. package/mevebu/libs/js/tools/url-parser.js +12 -13
  156. package/mevebu/libs/js/tools/utf8-bytes.js +6 -9
  157. package/mevebu/libs/js/tools/uuid-generator.js +107 -15
  158. package/mevebu/libs/js/tools/version-compare.js +3 -4
  159. package/mevebu/libs/js/tools/video-duration.js +5 -7
  160. package/mevebu/libs/js/tools/volume-converter.js +7 -9
  161. package/mevebu/libs/js/tools/weekly-planner.js +8 -10
  162. package/mevebu/libs/js/tools/weight-converter.js +7 -9
  163. package/mevebu/libs/js/tools/weighted-average.js +4 -5
  164. package/mevebu/libs/js/tools/word-counter.js +10 -13
  165. package/mevebu/libs/js/tools/word-limit.js +4 -5
  166. package/mevebu/libs/js/tools/yaml-formatter.js +6 -7
  167. package/package.json +1 -1
package/mevebu/index.html CHANGED
@@ -12,8 +12,8 @@
12
12
  document.documentElement.dataset.theme = getTheme();
13
13
  </script>
14
14
  </head>
15
- <body>
16
- <noscript><p style="padding:24px">mevebu needs JavaScript. Every tool runs in this page.</p></noscript>
15
+ <body class="flex min-h-dvh flex-col bg-void font-sans text-base text-ink antialiased">
16
+ <noscript><p class="p-6">mevebu needs JavaScript. Every tool runs in this page.</p></noscript>
17
17
  <div id="app"></div>
18
18
  <script type="module" src="./app/mevebu.js"></script>
19
19
  </body>
@@ -1,6 +1,11 @@
1
1
  // Shared utilities for every mevebu tool: DOM building, storage, clipboard,
2
2
  // crypto helpers, the bench controls, the modal, and the command palette.
3
- // Every class this file emits starts with mvb-.
3
+ //
4
+ // The interface is Tailwind, and this file is where the shared pieces of it live,
5
+ // so a control is written once instead of in 186 tools. Every class string below
6
+ // is a plain Tailwind utility list, and DESIGN.md section 3 pins the values they
7
+ // resolve to. A tool passes only the delta it needs: `class` is merged onto the
8
+ // helper's own class list, never substituted for it.
4
9
 
5
10
  import { svg, hasIcon } from "./icons.js";
6
11
  import { CATEGORIES, TOOLS, toolHref, getCategory } from "./registry.js";
@@ -46,7 +51,7 @@ export function clear(node) {
46
51
  return node;
47
52
  }
48
53
 
49
- export function icon(name, cls = "mvb-ico") {
54
+ export function icon(name, cls = "size-[18px] flex-none") {
50
55
  return svg(name, cls);
51
56
  }
52
57
 
@@ -152,33 +157,60 @@ export function download(filename, data, mime = "text/plain") {
152
157
  let toastTimer;
153
158
 
154
159
  export function toast(message) {
155
- let node = $("#mvb-toast");
160
+ let node = $("#toast");
156
161
  if (!node) {
157
- node = el("div", { id: "mvb-toast", class: "mvb-toast", role: "status", "aria-live": "polite" });
162
+ node = el("div", {
163
+ id: "toast",
164
+ class: "pointer-events-none fixed bottom-6 left-1/2 z-90 -translate-x-1/2 translate-y-3 rounded-r1 bg-ink px-4 py-2.5 text-sm font-semibold text-void opacity-0 transition-[opacity,translate] duration-150 data-[on]:translate-y-0 data-[on]:opacity-100",
165
+ role: "status",
166
+ "aria-live": "polite",
167
+ });
158
168
  document.body.append(node);
159
169
  }
160
170
  node.textContent = message;
161
- node.classList.add("mvb-on");
171
+ node.dataset.on = "";
162
172
  clearTimeout(toastTimer);
163
- toastTimer = setTimeout(() => node.classList.remove("mvb-on"), 1800);
173
+ toastTimer = setTimeout(() => delete node.dataset.on, 1800);
164
174
  }
165
175
 
166
176
  // ---------- modal ----------
167
177
 
168
178
  let lastFocus = null;
169
179
 
170
- /**
171
- * Opens a modal built from `body` nodes. Returns a close function.
172
- * Escape and backdrop clicks close it. Focus moves in and back out.
173
- */
174
- export function modal({ label, body, actions = [], onOpen, onClose, veilClass } = {}) {
180
+ /* One panel shape for the whole app: an optional sigil head, body copy, then an
181
+ action row that follows the same rule as a tool card (primary last, rightmost).
182
+ A close button is always in the corner, and Escape or a backdrop click does the
183
+ same thing. This is the only surface in the app that carries a shadow, because
184
+ it is the only one that genuinely floats above the page. */
185
+ const MODAL_BODY = "grid gap-2.5 [&_p]:text-pretty [&_p]:text-ink-2";
186
+
187
+ /** The head every modal opens with: a sigil for what the dialog is about, then its title. */
188
+ export function modalHead(iconName, title) {
189
+ return el(
190
+ "div",
191
+ { class: "flex items-center gap-3 pe-10" },
192
+ el("span", { class: "grid size-11 flex-none place-items-center rounded-xl border border-orbit bg-jade-soft text-jade [&_svg]:size-[22px]" }, icon(iconName)),
193
+ el("h2", { class: "text-xl font-bold tracking-[-0.02em]" }, title)
194
+ );
195
+ }
196
+
197
+ export function modal({ label, body, actions = [], onOpen, onClose, panelClass } = {}) {
175
198
  lastFocus = document.activeElement;
176
- const veil = el("div", { class: "mvb-veil" + (veilClass ? ` ${veilClass}` : "") });
199
+ const veil = el("div", {
200
+ class: "group fixed inset-0 z-80 grid items-start justify-items-center overflow-y-auto bg-[var(--scrim)] px-5 pt-[clamp(60px,9vh,110px)] pb-10 opacity-0 transition-opacity duration-200 data-[state=open]:opacity-100",
201
+ });
177
202
  const panel = el(
178
203
  "div",
179
- { class: "mvb-modal", role: "dialog", "aria-modal": "true", "aria-label": label },
180
- el("div", { class: "mvb-modal__body" }, ...body),
181
- actions.length ? el("div", { class: "mvb-modal__actions" }, ...actions) : null
204
+ {
205
+ class:
206
+ "relative grid gap-4 rounded-r3 border border-orbit bg-deck shadow-[var(--shadow)] transition-transform duration-200 ease-[var(--ease)] translate-y-3.5 scale-[0.97] group-data-[state=open]:translate-y-0 group-data-[state=open]:scale-100 " +
207
+ (panelClass ?? "w-[min(480px,100%)] p-[clamp(22px,3vw,28px)]"),
208
+ role: "dialog",
209
+ "aria-modal": "true",
210
+ "aria-label": label,
211
+ },
212
+ body ? el("div", { class: MODAL_BODY }, ...body) : null,
213
+ actions.length ? el("div", { class: "mt-1 flex flex-wrap items-center justify-end gap-2" }, ...actions) : null
182
214
  );
183
215
  veil.append(panel);
184
216
  document.body.append(veil);
@@ -188,7 +220,7 @@ export function modal({ label, body, actions = [], onOpen, onClose, veilClass }
188
220
  const close = () => {
189
221
  if (closed) return;
190
222
  closed = true;
191
- veil.classList.add("mvb-off");
223
+ delete veil.dataset.state;
192
224
  onClose?.();
193
225
  setTimeout(() => {
194
226
  veil.remove();
@@ -198,6 +230,17 @@ export function modal({ label, body, actions = [], onOpen, onClose, veilClass }
198
230
  if (lastFocus?.focus) lastFocus.focus();
199
231
  };
200
232
 
233
+ const closeButton = el("button", {
234
+ class: "absolute top-3 end-3 inline-flex size-9 cursor-pointer items-center justify-center rounded-r1 border border-transparent bg-transparent text-ink-3 transition-colors duration-150 hover:bg-deck-2 hover:text-ink",
235
+ type: "button",
236
+ "aria-label": "Close",
237
+ onclick: () => close(),
238
+ }, icon("x"));
239
+
240
+ // The button is inside the panel, after its content, so it never lands in the
241
+ // tab order ahead of the dialog's own controls.
242
+ panel.append(closeButton);
243
+
201
244
  const onKey = (event) => {
202
245
  if (event.key === "Escape") close();
203
246
  };
@@ -206,7 +249,9 @@ export function modal({ label, body, actions = [], onOpen, onClose, veilClass }
206
249
  });
207
250
  document.addEventListener("keydown", onKey);
208
251
 
209
- requestAnimationFrame(() => veil.classList.add("mvb-open"));
252
+ requestAnimationFrame(() => {
253
+ veil.dataset.state = "open";
254
+ });
210
255
  onOpen?.({ close, panel });
211
256
  return close;
212
257
  }
@@ -228,27 +273,32 @@ export function openPalette({ onPick } = {}) {
228
273
  }));
229
274
 
230
275
  const input = el("input", {
231
- class: "mvb-pal__in",
276
+ class: "h-[var(--ctl)] min-w-0 flex-1 border-0 bg-transparent text-md outline-none placeholder:text-ink-3",
232
277
  type: "search",
233
278
  placeholder: "Search tools",
234
279
  "aria-label": "Search tools",
235
280
  autocomplete: "off",
236
281
  spellcheck: "false",
237
282
  });
238
- const list = el("ul", { class: "mvb-pal__list", role: "listbox" });
239
- const count = el("p", { class: "mvb-pal__count" });
283
+ const list = el("ul", { class: "m-0 mt-1.5 max-h-[min(60vh,360px)] overflow-y-auto px-2 pb-2", role: "listbox" });
284
+ const count = el("p", { class: "m-0 px-[18px] pt-2 text-2xs text-ink-3" });
240
285
  const footer = el(
241
286
  "p",
242
- { class: "mvb-pal__foot" },
287
+ { class: "m-0 flex flex-wrap items-center gap-2.5 border-t border-orbit px-[18px] py-2.5 text-2xs text-ink-3" },
243
288
  "↑↓ navigate ",
244
- el("kbd", { class: "mvb-kbd" }, "↵"),
289
+ el("kbd", { class: "rounded-[5px] border border-orbit-hard px-[7px] py-px font-sans text-2xs leading-[1.45] text-ink-3" }, "↵"),
245
290
  " open ",
246
- el("kbd", { class: "mvb-kbd" }, "esc"),
291
+ el("kbd", { class: "rounded-[5px] border border-orbit-hard px-[7px] py-px font-sans text-2xs leading-[1.45] text-ink-3" }, "esc"),
247
292
  " close"
248
293
  );
249
294
 
250
295
  const body = [
251
- el("div", { class: "mvb-pal__head" }, icon("search", "mvb-ico mvb-ico--lg mvb-pal__icon"), input),
296
+ el(
297
+ "div",
298
+ { class: "flex items-center gap-3 border-b border-orbit py-4 pe-12 ps-[18px]" },
299
+ icon("search", "size-[26px] flex-none text-ink-3"),
300
+ input
301
+ ),
252
302
  count,
253
303
  list,
254
304
  footer,
@@ -269,23 +319,24 @@ export function openPalette({ onPick } = {}) {
269
319
  return el(
270
320
  "li",
271
321
  {
272
- class: "mvb-pal__item" + (i === index ? " mvb-on" : ""),
322
+ class:
323
+ "group flex w-full cursor-pointer items-center gap-3 rounded-r1 border-0 bg-transparent px-3 py-2.5 text-start text-ink transition-colors duration-150 data-[on]:bg-jade-soft",
273
324
  role: "option",
274
325
  dataset: { href: toolHref(tool) },
275
326
  "aria-selected": String(i === index),
276
327
  onmousemove: () => setIndex(i),
277
328
  onclick: () => pick(i),
278
329
  },
279
- el("span", { class: "mvb-pal__sigil" }, icon(tool.icon)),
280
- el("span", { class: "mvb-pal__text" },
281
- el("span", { class: "mvb-pal__name" }, tool.name),
282
- el("span", { class: "mvb-pal__note" }, tool.note)),
283
- el("span", { class: "mvb-pal__cat" }, category?.name ?? "")
330
+ el("span", { class: "grid size-8 flex-none place-items-center rounded-lg bg-deck-2 text-jade [&_svg]:size-4" }, icon(tool.icon)),
331
+ el("span", { class: "grid min-w-0 flex-1 gap-px" },
332
+ el("span", { class: "text-sm font-semibold tracking-[-0.01em] group-data-[on]:text-jade" }, tool.name),
333
+ el("span", { class: "overflow-hidden text-2xs text-ellipsis whitespace-nowrap text-ink-2" }, tool.note)),
334
+ el("span", { class: "whitespace-nowrap text-2xs text-ink-3" }, category?.name ?? "")
284
335
  );
285
336
  })
286
337
  );
287
338
  if (!current.length) {
288
- list.append(el("li", { class: "mvb-pal__empty" }, "No tool matches “", query, "”."));
339
+ list.append(el("li", { class: "p-[18px] text-sm text-ink-3" }, "No tool matches “", query, "”."));
289
340
  }
290
341
  count.textContent = q
291
342
  ? `${current.length} of ${tools.length} tools`
@@ -295,9 +346,12 @@ export function openPalette({ onPick } = {}) {
295
346
  function setIndex(next) {
296
347
  if (!current.length) return;
297
348
  index = (next + current.length) % current.length;
298
- for (const item of list.children) item.classList.remove("mvb-on");
299
- list.children[index]?.classList.add("mvb-on");
300
- list.children[index]?.scrollIntoView({ block: "nearest" });
349
+ for (const item of list.children) delete item.dataset.on;
350
+ const active = list.children[index];
351
+ if (active) {
352
+ active.dataset.on = "";
353
+ active.scrollIntoView({ block: "nearest" });
354
+ }
301
355
  }
302
356
 
303
357
  function pick(at) {
@@ -310,6 +364,7 @@ export function openPalette({ onPick } = {}) {
310
364
  const close = modal({
311
365
  label: "Search tools",
312
366
  body,
367
+ panelClass: "w-[min(640px,100%)] overflow-hidden p-0",
313
368
  onOpen: ({ close: doClose, panel: modalPanel }) => {
314
369
  input.focus();
315
370
  input.addEventListener("input", () => paint(input.value));
@@ -321,7 +376,6 @@ export function openPalette({ onPick } = {}) {
321
376
  void modalPanel;
322
377
  void doClose;
323
378
  },
324
- veilClass: "mvb-veil--pal",
325
379
  });
326
380
 
327
381
  paint("");
@@ -363,29 +417,31 @@ export function debounce(fn, ms = 120) {
363
417
 
364
418
  /** Two column bench: controls on the left, the result on the right. */
365
419
  export function bench(side, main) {
366
- return el("div", { class: "mvb-bench" }, el("div", { class: "mvb-bench__side" }, side), el("div", { class: "mvb-bench__main" }, main));
420
+ return el(
421
+ "div",
422
+ { class: "grid items-start gap-5 min-[60rem]:grid-cols-[minmax(0,21rem)_minmax(0,1fr)] min-[60rem]:gap-6" },
423
+ el("div", { class: "min-w-0" }, side),
424
+ el("div", { class: "min-w-0" }, main)
425
+ );
367
426
  }
368
427
 
369
428
  /** Single centred stage for tools whose whole interface is one object. */
370
429
  export function stage(...nodes) {
371
- return el("div", { class: "mvb-stage" }, ...nodes);
430
+ return el("div", { class: "mx-auto grid max-w-[560px] justify-items-center gap-4 text-center max-[45rem]:justify-items-stretch max-[45rem]:text-start" }, ...nodes);
372
431
  }
373
432
 
374
433
  export function stack(...nodes) {
375
- return el("div", { class: "mvb-stack" }, ...nodes);
434
+ return el("div", { class: "grid content-start gap-4" }, ...nodes);
376
435
  }
377
436
 
378
- /**
379
- * Row of controls. Pass anything; it sits in the row. Use `<.mvb-actions>` to
380
- * mark "thumbs-up submit" slots that should sit on the right of a tool form.
381
- */
437
+ /** Row of controls. Pass anything; it sits in the row. */
382
438
  export function row(...nodes) {
383
- return el("div", { class: "mvb-row" }, ...nodes);
439
+ return el("div", { class: "flex min-w-0 flex-wrap items-center gap-2" }, ...nodes);
384
440
  }
385
441
 
386
442
  /** Right-aligned actions block. Use this for primary action buttons. */
387
443
  export function actions(...nodes) {
388
- return el("div", { class: "mvb-actions" }, ...nodes);
444
+ return el("div", { class: "flex flex-wrap items-center justify-end gap-2" }, ...nodes);
389
445
  }
390
446
 
391
447
  // ---------- tool card ----------
@@ -400,39 +456,76 @@ export function actions(...nodes) {
400
456
  * slot.append(card.node);
401
457
  */
402
458
  export function toolcard({ head, body, foot } = {}) {
403
- const headNode = el("div", { class: "mvb-toolcard__head", hidden: !head }, head);
404
- const bodyNode = el("div", { class: "mvb-toolcard__body" }, body);
405
- const footNode = el("div", { class: "mvb-toolcard__foot", hidden: !foot }, foot);
406
- const node = el("div", { class: "mvb-toolcard" }, headNode, bodyNode, footNode);
459
+ const band = "border-orbit bg-deck-2 px-[var(--card-pad)] py-[var(--band-pad)] max-[60rem]:px-[18px]";
460
+ const headNode = el("div", { class: `flex flex-wrap items-center gap-2 border-b ${band}`, hidden: !head }, head);
461
+ const bodyNode = el("div", { class: "flex-1 p-[var(--card-pad)] max-[60rem]:p-[18px]" }, body);
462
+ const footNode = el("div", { class: `flex flex-wrap items-center justify-end gap-2 border-t ${band}`, hidden: !foot }, foot);
463
+ const node = el("div", { class: "flex flex-col overflow-clip rounded-r2 border border-orbit bg-deck" }, headNode, bodyNode, footNode);
407
464
  return { node, head: headNode, body: bodyNode, foot: footNode };
408
465
  }
409
466
 
410
467
  // ---------- controls ----------
411
468
 
412
- /**
413
- * A panel field: label on top, the control, optional hint underneath.
414
- * Pass a `state` ("error" | "good" | "warning") for status colours.
415
- */
469
+ /** A panel field: label on top, the control, optional hint underneath.
470
+ * Pass a `state` ("error" | "good" | "warning") for status colours. The state is
471
+ * drawn as an outline, so it never fights the border or the focus ring. */
472
+ const HINT_TONE = { error: "text-danger", good: "text-jade", warning: "text-ink" };
473
+ const EDGE_TONE = {
474
+ error: "outline-2 -outline-offset-2 outline-danger",
475
+ good: "outline-2 -outline-offset-2 outline-jade",
476
+ warning: "outline-2 -outline-offset-2 outline-star",
477
+ };
478
+
416
479
  export function panel(labelText, control, hint, state) {
480
+ if (state && EDGE_TONE[state] && control?.classList) control.classList.add(...EDGE_TONE[state].split(" "));
417
481
  return el(
418
482
  "label",
419
- { class: "mvb-field" + (state ? ` mvb-field--${state}` : "") },
420
- labelText ? el("span", { class: "mvb-field__label" }, labelText) : null,
483
+ { class: "grid min-w-0 content-start gap-1.5" },
484
+ labelText ? el("span", { class: "text-sm font-semibold text-ink-2" }, labelText) : null,
421
485
  control,
422
- hint ? el("span", { class: "mvb-field__hint" }, hint) : null
486
+ hint ? el("span", { class: `text-2xs ${HINT_TONE[state] ?? "text-ink-3"}` }, hint) : null
423
487
  );
424
488
  }
425
489
 
426
- export function textInput(attrs = {}) {
427
- return el("input", { class: "mvb-in", spellcheck: "false", autocomplete: "off", ...attrs });
490
+ /* One field skin. You type on the raised deck surface, the same height as every
491
+ button beside it, and the focus ring is jade. Only the properties that never
492
+ change live in the base; width, padding and font size belong to the variant,
493
+ because two utilities for one property in the same layer resolve by sheet
494
+ order, not by the order they are written in the class list. */
495
+ const FIELD =
496
+ "min-w-0 rounded-r1 border border-orbit bg-deck text-ink transition-[border-color,box-shadow] duration-150 placeholder:text-ink-3 hover:border-orbit-hard focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft";
497
+
498
+ /** Merge a tool's own class onto a helper's class list. */
499
+ function withExtra(base, extra) {
500
+ return extra ? `${base} ${extra}` : base;
428
501
  }
429
502
 
430
- export function textArea(attrs = {}) {
431
- return el("textarea", { class: "mvb-in mvb-in--area", rows: 6, spellcheck: "false", ...attrs });
503
+ export function textInput({ mono, class: extra, ...attrs } = {}) {
504
+ return el("input", {
505
+ class: withExtra(`${FIELD} w-full min-h-[var(--ctl)] px-3 py-2.5 ${mono ? "font-mono text-sm" : "text-base"}`, extra),
506
+ spellcheck: "false",
507
+ autocomplete: "off",
508
+ ...attrs,
509
+ });
432
510
  }
433
511
 
434
- export function selectInput(options, attrs = {}) {
435
- const node = el("select", { class: "mvb-in mvb-in--select", ...attrs });
512
+ export function textArea({ class: extra, rows = 6, ...attrs } = {}) {
513
+ return el("textarea", {
514
+ class: withExtra(`${FIELD} w-full min-h-24 resize-y px-3 py-2.5 font-mono text-sm leading-normal`, extra),
515
+ rows,
516
+ spellcheck: "false",
517
+ ...attrs,
518
+ });
519
+ }
520
+
521
+ export function selectInput(options, { class: extra, ...attrs } = {}) {
522
+ const node = el("select", {
523
+ class: withExtra(
524
+ `${FIELD} w-full min-h-[var(--ctl)] cursor-pointer py-2.5 ps-3 pe-9 appearance-none bg-no-repeat bg-[position:right_12px_center] bg-[length:14px] bg-[image:var(--caret)] text-base`,
525
+ extra
526
+ ),
527
+ ...attrs,
528
+ });
436
529
  for (const option of options) {
437
530
  const { value, label } = typeof option === "string" ? { value: option, label: option } : option;
438
531
  node.append(el("option", { value, selected: value === attrs.value }, label));
@@ -440,48 +533,85 @@ export function selectInput(options, attrs = {}) {
440
533
  return node;
441
534
  }
442
535
 
443
- export function numberInput(attrs = {}) {
444
- return el("input", { class: "mvb-in mvb-in--num", type: "number", inputmode: "numeric", ...attrs });
536
+ export function numberInput({ class: extra, ...attrs } = {}) {
537
+ return el("input", {
538
+ class: withExtra(`${FIELD} w-24 min-h-[var(--ctl)] px-3 py-2.5 text-base`, extra),
539
+ type: "number",
540
+ inputmode: "numeric",
541
+ ...attrs,
542
+ });
445
543
  }
446
544
 
447
- export function rangeInput(attrs = {}) {
448
- return el("input", { class: "mvb-range", type: "range", ...attrs });
545
+ export function rangeInput({ class: extra, ...attrs } = {}) {
546
+ return el("input", { class: withExtra("min-h-[var(--ctl)] w-full accent-[var(--jade)]", extra), type: "range", ...attrs });
449
547
  }
450
548
 
451
- export function colorInput(attrs = {}) {
452
- return el("input", { class: "mvb-pick", type: "color", ...attrs });
549
+ const PICK =
550
+ "h-[var(--ctl)] w-[52px] flex-none cursor-pointer rounded-r1 border border-orbit bg-deck p-0.5 hover:border-orbit-hard focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft";
551
+
552
+ export function colorInput({ class: extra, ...attrs } = {}) {
553
+ return el("input", { class: withExtra(PICK, extra), type: "color", ...attrs });
453
554
  }
454
555
 
455
- export function fileInput(attrs = {}) {
456
- return el("input", { class: "mvb-in mvb-in--file", type: "file", ...attrs });
556
+ export function fileInput({ class: extra, ...attrs } = {}) {
557
+ return el("input", {
558
+ class: withExtra(
559
+ `${FIELD} w-full min-h-[var(--ctl)] p-1.5 text-sm [&::file-selector-button]:me-2.5 [&::file-selector-button]:cursor-pointer [&::file-selector-button]:rounded-r1 [&::file-selector-button]:border [&::file-selector-button]:border-orbit-hard [&::file-selector-button]:bg-deck-2 [&::file-selector-button]:px-2.5 [&::file-selector-button]:py-1 [&::file-selector-button]:text-2xs [&::file-selector-button]:font-semibold [&::file-selector-button]:text-ink`,
560
+ extra
561
+ ),
562
+ type: "file",
563
+ ...attrs,
564
+ });
457
565
  }
458
566
 
459
- export function passwordInput(attrs = {}) {
460
- return el("input", { class: "mvb-in mvb-in--mono", type: "password", autocomplete: "new-password", ...attrs });
567
+ export function passwordInput({ class: extra, ...attrs } = {}) {
568
+ return el("input", {
569
+ class: withExtra(`${FIELD} w-full min-h-[var(--ctl)] px-3 py-2.5 font-mono text-sm`, extra),
570
+ type: "password",
571
+ autocomplete: "new-password",
572
+ ...attrs,
573
+ });
461
574
  }
462
575
 
463
576
  export function checkbox(labelText, attrs = {}) {
464
- const box = el("input", { class: "mvb-check__box", type: "checkbox", ...attrs });
465
- const node = el("label", { class: "mvb-check" }, box, el("span", {}, labelText));
577
+ const box = el("input", { class: "m-0 size-4 accent-[var(--jade)]", type: "checkbox", ...attrs });
578
+ const node = el(
579
+ "label",
580
+ { class: "inline-flex min-h-[var(--ctl)] cursor-pointer items-center gap-2 rounded-r1 border border-orbit bg-deck py-2 ps-2.5 pe-3 text-sm transition-[border-color,background-color] duration-150 has-checked:border-jade has-checked:bg-jade-soft" },
581
+ box,
582
+ el("span", {}, labelText)
583
+ );
466
584
  node.input = box;
467
585
  return node;
468
586
  }
469
587
 
470
588
  export function switchControl(labelText, attrs = {}) {
471
- const box = el("input", { class: "mvb-switch__box", type: "checkbox", role: "switch", ...attrs });
472
- const track = el("span", { class: "mvb-switch__track" }, el("span", { class: "mvb-switch__thumb" }));
473
- const node = el("label", { class: "mvb-switch" }, box, track, el("span", { class: "mvb-switch__label" }, labelText));
589
+ const box = el("input", { class: "peer pointer-events-none absolute opacity-0", type: "checkbox", role: "switch", ...attrs });
590
+ const track = el(
591
+ "span",
592
+ { class: "relative h-[22px] w-[38px] rounded-full bg-track transition-colors duration-150 peer-checked:bg-jade peer-checked:[&>span]:translate-x-4 peer-focus-visible:ring-3 peer-focus-visible:ring-jade-soft" },
593
+ el("span", { class: "absolute top-0.5 left-0.5 size-[18px] rounded-full bg-deck shadow-[0_1px_2px_rgb(0_0_0/0.18)] transition-transform duration-150" })
594
+ );
595
+ const node = el("label", { class: "inline-flex min-h-[var(--ctl)] cursor-pointer items-center gap-2.5 select-none text-sm" }, box, track, el("span", {}, labelText));
474
596
  node.input = box;
475
597
  return node;
476
598
  }
477
599
 
478
- /** Primary button. `kind: "fill"` is the high-emphasis action. */
479
- export function button(labelText, { icon: iconName, kind = "plain", size, ...rest } = {}) {
480
- const cls = ["mvb-btn", `mvb-btn--${kind}`];
481
- if (size) cls.push(`mvb-btn--${size}`);
600
+ /* One button: one height, one padding, three weights. `fill` is the high
601
+ emphasis action and the only button that carries the accent. There is no size
602
+ modifier, because a tool that needs a smaller button needs fewer buttons. */
603
+ const BTN =
604
+ "inline-flex min-h-[var(--ctl)] cursor-pointer items-center justify-center gap-2 rounded-r1 border text-sm font-semibold whitespace-nowrap no-underline transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-45";
605
+ const BTN_KIND = {
606
+ fill: "border-jade bg-jade px-4 text-on-jade hover:border-jade-deep hover:bg-jade-deep",
607
+ plain: "border-orbit bg-deck px-4 text-ink hover:border-orbit-hard hover:bg-deck-2",
608
+ quiet: "border-transparent bg-transparent px-2.5 text-ink-2 hover:bg-deck-2 hover:text-ink",
609
+ };
610
+
611
+ export function button(labelText, { icon: iconName, kind = "plain", class: extra, ...rest } = {}) {
482
612
  return el(
483
613
  "button",
484
- { class: cls.join(" "), type: "button", ...rest },
614
+ { class: withExtra(`${BTN} ${BTN_KIND[kind] ?? BTN_KIND.plain}`, extra), type: "button", ...rest },
485
615
  iconName ? icon(iconName) : null,
486
616
  el("span", {}, labelText)
487
617
  );
@@ -514,23 +644,23 @@ export function downloadButton(getData, filename, labelText = "Download", mime =
514
644
  * Single choice chips. `items` is [{ value, label }]. Returns the wrapper with
515
645
  * a `.select(value)` method so the tool can read the current value.
516
646
  */
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" });
647
+ const CHIP =
648
+ "min-h-[var(--ctl)] cursor-pointer rounded-r1 border border-orbit bg-deck px-[13px] text-sm font-medium text-ink-2 transition-colors duration-150 hover:border-orbit-hard hover:text-ink data-[on]:border-jade data-[on]:bg-jade-soft data-[on]:text-ink";
649
+
650
+ export function chipRow(items, { value, onChange, label } = {}) {
651
+ const wrap = el("div", { class: "flex flex-wrap gap-1.5", role: "group", "aria-label": label || "Options" });
519
652
  let current = value;
520
653
  const paint = () => {
521
654
  for (const chip of wrap.children) {
522
655
  const item = items.find((entry) => entry.label === chip.textContent);
523
656
  const on = item?.value === current;
524
- chip.classList.toggle("mvb-on", on);
657
+ if (on) chip.dataset.on = "";
658
+ else delete chip.dataset.on;
525
659
  chip.setAttribute("aria-pressed", String(on));
526
660
  }
527
661
  };
528
662
  for (const item of items) {
529
- wrap.append(el(
530
- "button",
531
- { class: "mvb-chip", type: "button", onclick: () => wrap.select(item.value) },
532
- item.label
533
- ));
663
+ wrap.append(el("button", { class: CHIP, type: "button", onclick: () => wrap.select(item.value) }, item.label));
534
664
  }
535
665
  wrap.select = (next) => {
536
666
  current = next;
@@ -544,7 +674,7 @@ export function chipRow(items, { value, onChange, label, size = "sm" } = {}) {
544
674
  /** Independent toggles. Returns the wrapper with `.values()` reading the boxes. */
545
675
  export function toggleRow(items) {
546
676
  const boxes = new Map();
547
- const wrap = el("div", { class: "mvb-chips" });
677
+ const wrap = el("div", { class: "flex flex-wrap gap-1.5" });
548
678
  for (const item of items) {
549
679
  const node = checkbox(item.label, { checked: item.checked !== false });
550
680
  boxes.set(item.value, node.input);
@@ -556,10 +686,14 @@ export function toggleRow(items) {
556
686
 
557
687
  // ---------- readouts ----------
558
688
 
689
+ /* The recessed half of the surface pair, so a result never reads as an input. */
690
+ const OUT =
691
+ "m-0 max-h-[340px] w-full overflow-auto rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3 font-mono text-sm leading-normal whitespace-pre-wrap [overflow-wrap:anywhere]";
692
+
559
693
  export function output({ value = "", rows = 6, tag = "pre", readonly = true } = {}) {
560
694
  const node = tag === "pre"
561
- ? el("pre", { class: "mvb-out" }, value)
562
- : el("textarea", { class: "mvb-out", readonly, rows, value });
695
+ ? el("pre", { class: `${OUT} [&:empty]:border-dashed [&:empty]:bg-transparent` }, value)
696
+ : el("textarea", { class: `${OUT} resize-y`, readonly, rows, value });
563
697
  if (readonly) node.setAttribute("readonly", "");
564
698
  // A textarea ignores textContent once its value has been assigned, so the
565
699
  // readout has to write through `.value`.
@@ -571,8 +705,14 @@ export function output({ value = "", rows = 6, tag = "pre", readonly = true } =
571
705
  }
572
706
 
573
707
  /** A short line under a control. `kind` is one of error, warn, note, good. */
708
+ const NOTE_KIND = {
709
+ error: "border-s-danger text-danger",
710
+ warn: "border-s-star font-medium text-ink",
711
+ good: "border-s-jade text-jade",
712
+ };
713
+
574
714
  export function notice(kind = "note") {
575
- const node = el("p", { class: `mvb-note mvb-note--${kind}`, role: kind === "error" ? "alert" : null });
715
+ const node = el("p", { class: `border-s-[3px] ps-2 text-sm ${NOTE_KIND[kind] ?? "border-transparent text-ink-3"}`, role: kind === "error" ? "alert" : null });
576
716
  node.write = (text) => {
577
717
  node.textContent = text || "";
578
718
  node.hidden = !text;
@@ -584,8 +724,8 @@ export function notice(kind = "note") {
584
724
  export function blank(iconName, text) {
585
725
  return el(
586
726
  "div",
587
- { class: "mvb-blank" },
588
- iconName ? icon(iconName, "mvb-ico mvb-ico--lg") : null,
727
+ { class: "grid justify-items-center gap-2.5 rounded-r2 border border-dashed border-orbit-hard bg-deck-2 px-4 py-[clamp(28px,6vw,56px)] text-center text-ink-3 [&_p]:max-w-[34ch]" },
728
+ iconName ? icon(iconName, "size-[26px] flex-none") : null,
589
729
  el("p", {}, text)
590
730
  );
591
731
  }
@@ -593,7 +733,7 @@ export function blank(iconName, text) {
593
733
  // ---------- version ----------
594
734
 
595
735
  // Kept in one place. The CLI reads the same value from package.json.
596
- export const VERSION = "1.2.0";
736
+ export const VERSION = "1.4.0";
597
737
  export const PACKAGE_NAME = "mevebu";
598
738
  export const REPO_SLUG = "mevebu/mevebu";
599
739
 
@@ -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",