mevebu 1.4.1 → 1.4.3

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.
@@ -130,7 +130,7 @@ function masthead() {
130
130
  el(
131
131
  "div",
132
132
  { class: "mx-[var(--margin)] flex h-[var(--bar)] items-center gap-[clamp(14px,3vw,32px)]" },
133
- el("a", { class: "inline-flex flex-none items-center gap-2.5 text-lg font-bold tracking-[-0.01em] text-ink no-underline", href: "#/" },
133
+ el("a", { class: "inline-flex min-h-11 flex-none items-center gap-2.5 text-lg font-bold tracking-[-0.01em] text-ink no-underline", href: "#/" },
134
134
  el("img", { class: "block size-[30px] rounded-[9px] shadow-[0_0_0_1px_var(--orbit)]", src: "./icon.png", alt: "", width: "30", height: "30" }), "mevebu"),
135
135
  nav,
136
136
  el("div", { class: "flex items-center gap-0.5 max-[45rem]:ms-auto" }, searchButton(), themeBtn, menuBtn)
@@ -259,7 +259,9 @@ function footer() {
259
259
  },
260
260
  });
261
261
 
262
- const FOOT_LINK = "text-sm text-ink-2 no-underline hover:text-jade";
262
+ // A finger needs 44px on a phone, so the whole list is a comfortable target
263
+ // there. The desktop row stays as it was, at text height.
264
+ const FOOT_LINK = "inline-flex items-center text-sm text-ink-2 no-underline hover:text-jade max-[45rem]:min-h-11";
263
265
  const group = (title, links) =>
264
266
  el(
265
267
  "div",
@@ -280,7 +282,7 @@ function footer() {
280
282
  el(
281
283
  "div",
282
284
  { class: "grid gap-1.5" },
283
- el("a", { class: "inline-flex items-center gap-[9px] text-md font-bold tracking-[-0.01em] text-ink no-underline", href: "#/" },
285
+ el("a", { class: "inline-flex min-h-11 items-center gap-[9px] text-md font-bold tracking-[-0.01em] text-ink no-underline", href: "#/" },
284
286
  el("img", { class: "block size-[34px] rounded-[11px] shadow-[0_0_0_1px_var(--orbit)]", src: "./icon.png", alt: "", width: "34", height: "34" }), "mevebu"),
285
287
  el("p", { class: "m-0 max-w-[30ch] text-sm leading-[1.5] text-ink-3" }, "Small tools that run in your browser. No accounts, no uploads, no tracking.")
286
288
  ),
@@ -315,7 +317,7 @@ function footer() {
315
317
  { class: "m-0 flex items-center gap-2" },
316
318
  el("button", {
317
319
  class:
318
- "inline-flex min-h-7 cursor-pointer items-center gap-1.5 rounded-full border border-orbit bg-transparent px-2.5 py-[3px] font-sans text-2xs tabular-nums text-ink-3 transition-colors duration-150 hover:border-orbit-hard hover:text-ink data-[new]:border-jade data-[new]:bg-jade-soft data-[new]:font-semibold data-[new]:text-jade data-[new]:hover:bg-jade data-[new]:hover:text-on-jade",
320
+ "inline-flex min-h-7 cursor-pointer items-center gap-1.5 rounded-full border border-orbit bg-transparent px-2.5 py-[3px] font-sans text-2xs tabular-nums text-ink-3 transition-colors duration-150 hover:border-orbit-hard hover:text-ink data-[new]:border-jade data-[new]:bg-jade-soft data-[new]:font-semibold data-[new]:text-jade data-[new]:hover:bg-jade data-[new]:hover:text-on-jade max-[45rem]:min-h-11",
319
321
  id: "version",
320
322
  type: "button",
321
323
  title: "Check for updates",
@@ -1558,6 +1558,9 @@
1558
1558
  .max-\[45rem\]\:inline-flex {
1559
1559
  display: inline-flex;
1560
1560
  }
1561
+ .max-\[45rem\]\:min-h-11 {
1562
+ min-height: calc(var(--spacing) * 11);
1563
+ }
1561
1564
  .max-\[45rem\]\:\[grid-template-columns\:minmax\(0\,1fr\)_62px\] {
1562
1565
  grid-template-columns: minmax(0,1fr) 62px;
1563
1566
  }
@@ -608,7 +608,8 @@ export function switchControl(labelText, attrs = {}) {
608
608
 
609
609
  /* One button: one height, one padding, three weights. `fill` is the high
610
610
  emphasis action and the only button that carries the accent. There is no size
611
- modifier, because a tool that needs a smaller button needs fewer buttons. */
611
+ modifier, because a tool that needs a smaller button needs fewer buttons.
612
+ `tag: "a"` lets a link wear the same skin, so no tool hand writes the class. */
612
613
  const BTN =
613
614
  "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";
614
615
  const BTN_KIND = {
@@ -617,10 +618,10 @@ const BTN_KIND = {
617
618
  quiet: "border-transparent bg-transparent px-2.5 text-ink-2 hover:bg-deck-2 hover:text-ink",
618
619
  };
619
620
 
620
- export function button(labelText, { icon: iconName, kind = "plain", class: extra, ...rest } = {}) {
621
+ export function button(labelText, { icon: iconName, kind = "plain", class: extra, tag = "button", ...rest } = {}) {
621
622
  return el(
622
- "button",
623
- { class: withExtra(`${BTN} ${BTN_KIND[kind] ?? BTN_KIND.plain}`, extra), type: "button", ...rest },
623
+ tag,
624
+ { class: withExtra(`${BTN} ${BTN_KIND[kind] ?? BTN_KIND.plain}`, extra), ...(tag === "button" ? { type: "button" } : null), ...rest },
624
625
  iconName ? icon(iconName) : null,
625
626
  el("span", {}, labelText)
626
627
  );
@@ -742,7 +743,7 @@ export function blank(iconName, text) {
742
743
  // ---------- version ----------
743
744
 
744
745
  // Kept in one place. The CLI reads the same value from package.json.
745
- export const VERSION = "1.4.1";
746
+ export const VERSION = "1.4.3";
746
747
  export const PACKAGE_NAME = "mevebu";
747
748
  export const REPO_SLUG = "mevebu/mevebu";
748
749
 
@@ -403,9 +403,9 @@ export const TOOLS = [
403
403
  note: "Make a QR code from text or a link.",
404
404
  about: "Download PNG for chat or SVG for print.",
405
405
  tutorial: {
406
- intro: "Generate a QR code from any text or link, with medium error correction.",
406
+ intro: "Generate a QR code from any text or link.",
407
407
  steps: [
408
- "Type or paste your text or link on the left.",
408
+ "Type or paste your text or link.",
409
409
  "Set the size in pixels, between 120 and 1024.",
410
410
  "Save PNG for chat or SVG for print. Copy image puts the PNG on the clipboard.",
411
411
  ],
@@ -419,13 +419,13 @@ export const TOOLS = [
419
419
  name: "QR Scanner",
420
420
  icon: "scan-line",
421
421
  note: "Read a QR code with your camera or an image.",
422
- about: "The camera feed stays in this tab. Nothing is recorded or sent.",
422
+ about: "Read the text inside a QR code, with the camera or an image file.",
423
423
  tutorial: {
424
- intro: "Read a QR code with your camera or from an image file. Decoding happens in this tab.",
424
+ intro: "Point the camera at a code, or drop in an image. The decoded text is ready to copy.",
425
425
  steps: [
426
426
  "Press Start camera and point it at a code. Allow the camera permission.",
427
427
  "Or drop an image on the box, or pick one with Choose an image.",
428
- "The decoded text appears below. Copy it, or open the link if it is a URL.",
428
+ "Copy the decoded text, or open it if it is a link.",
429
429
  ],
430
430
  },
431
431
  keywords: "read decode camera image photo scan",
@@ -443,7 +443,7 @@ export const TOOLS = [
443
443
  tutorial: {
444
444
  intro: "Type or paste text and read the counts as they change.",
445
445
  steps: [
446
- "Type or paste your text on the left.",
446
+ "Type or paste your text.",
447
447
  "The counts and the reading time update as you type.",
448
448
  "Set a word limit to track how close you are, then Copy summary.",
449
449
  ],
@@ -480,7 +480,7 @@ export const TOOLS = [
480
480
  intro: "Count the lines in a block of text.",
481
481
  steps: [
482
482
  "Paste your text.",
483
- "Total, filled and blank lines appear on the right.",
483
+ "Total, filled and blank counts follow as you type.",
484
484
  ],
485
485
  },
486
486
  keywords: "lines blank empty rows count",
@@ -534,7 +534,7 @@ export const TOOLS = [
534
534
  steps: [
535
535
  "Paste your text, then set the find and replace values.",
536
536
  "Toggle Regex to match a pattern, or Ignore case to match either case.",
537
- "Press Replace all. The count of replacements is shown.",
537
+ "Press Replace all to change every match, or Replace one for the first one only.",
538
538
  ],
539
539
  },
540
540
  keywords: "search replace regex substitute text",
@@ -12,8 +12,10 @@ function tile(label) {
12
12
  return node;
13
13
  }
14
14
 
15
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
16
+
15
17
  export function mount(slot) {
16
- const source = textArea({ rows: 10, "aria-label": "Text", placeholder: "Type or paste text" });
18
+ const source = textArea({ rows: 10, "aria-label": "Text" });
17
19
  const limitField = numberInput({ min: "0", placeholder: "No limit", "aria-label": "Character limit" });
18
20
 
19
21
  const totalTile = tile("Characters");
@@ -29,9 +31,9 @@ export function mount(slot) {
29
31
 
30
32
  function summary() {
31
33
  return [
32
- `${counts.total} characters`,
33
- `${counts.noSpace} without spaces`,
34
- `${counts.words} words`].join("\n");
34
+ plural(counts.total, "character"),
35
+ plural(counts.noSpace, "character") + " without spaces",
36
+ plural(counts.words, "word")].join("\n");
35
37
  }
36
38
 
37
39
  function run() {
@@ -45,11 +47,13 @@ export function mount(slot) {
45
47
  wordsTile.set(String(counts.words));
46
48
 
47
49
  const limit = parseInt(limitField.value, 10) || 0;
50
+ // The track is only worth showing once a limit exists to fill it.
51
+ meter.hidden = limit <= 0;
48
52
  if (limit > 0) {
49
53
  const ratio = counts.total / limit;
50
54
  fill.style.width = `${Math.min(100, ratio * 100)}%`;
51
55
  fill.dataset.level = ratio > 1 ? "weak" : ratio > 0.8 ? "ok" : "strong";
52
- limitNote.write(ratio > 1 ? `${counts.total - limit} over the limit.` : `${limit - counts.total} characters left.`);
56
+ limitNote.write(ratio > 1 ? `${counts.total - limit} over the limit.` : `${plural(limit - counts.total, "character")} left.`);
53
57
  } else {
54
58
  fill.style.width = "0%";
55
59
  delete fill.dataset.level;
@@ -1,16 +1,16 @@
1
1
  // Find and replace, plain text or a regular expression, with a live match count.
2
- import { bench, stack, panel, textArea, textInput, toggleRow, notice, button, copyButton, actions, toolcard } from "../core.js";
2
+ import { bench, stack, panel, textArea, textInput, output, toggleRow, notice, button, copyButton, actions, toolcard } from "../core.js";
3
3
 
4
4
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
5
 
6
6
  export function mount(slot) {
7
- const source = textArea({ rows: 9, "aria-label": "Text", placeholder: "Paste your text" });
8
- const findField = textInput({ placeholder: "Find", "aria-label": "Find" });
9
- const replaceField = textInput({ placeholder: "Replace with", "aria-label": "Replace with" });
7
+ const source = textArea({ rows: 9, "aria-label": "Text" });
8
+ const findField = textInput({ "aria-label": "Find" });
9
+ const replaceField = textInput({ "aria-label": "Replace with" });
10
10
  const options = toggleRow([
11
11
  { value: "regex", label: "Regex", checked: false },
12
12
  { value: "ignore", label: "Ignore case", checked: false }]);
13
- const result = textArea({ rows: 9, readonly: true, "aria-label": "Result" });
13
+ const result = output({ tag: "textarea", rows: 9 });
14
14
  const error = notice("error");
15
15
  const countNote = notice("note");
16
16
 
@@ -41,15 +41,21 @@ export function mount(slot) {
41
41
  return;
42
42
  }
43
43
  const matches = source.value.match(pattern) || [];
44
- result.value = source.value.replace(pattern, replaceField.value);
44
+ result.value = source.value.replace(pattern, replacement());
45
45
  countNote.write(matches.length === 1 ? "1 match." : `${matches.length} matches.`);
46
46
  }
47
47
 
48
+ // With Regex off the field is literal text, so a dollar sign in it stays one
49
+ // instead of reading as $& or $$.
50
+ function replacement() {
51
+ return options.values().includes("regex") ? replaceField.value : replaceField.value.replace(/\$/g, "$$$$");
52
+ }
53
+
48
54
  function replaceOne() {
49
55
  const pattern = build(false);
50
56
  if (pattern === "error") return;
51
57
  if (!pattern) return;
52
- source.value = source.value.replace(pattern, replaceField.value);
58
+ source.value = source.value.replace(pattern, replacement());
53
59
  run();
54
60
  }
55
61
 
@@ -57,7 +63,7 @@ export function mount(slot) {
57
63
  const pattern = build(true);
58
64
  if (pattern === "error") return;
59
65
  if (!pattern) return;
60
- source.value = source.value.replace(pattern, replaceField.value);
66
+ source.value = source.value.replace(pattern, replacement());
61
67
  run();
62
68
  }
63
69
 
@@ -8,6 +8,8 @@ function tile(label) {
8
8
  return node;
9
9
  }
10
10
 
11
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
12
+
11
13
  export function mount(slot) {
12
14
  const source = textArea({ rows: 14, "aria-label": "Text", placeholder: "Paste text, one entry per line" });
13
15
 
@@ -20,7 +22,7 @@ export function mount(slot) {
20
22
 
21
23
  function summary() {
22
24
  return [
23
- `${counts.total} total lines`,
25
+ `${plural(counts.total, "line")} total`,
24
26
  `${counts.filled} filled`,
25
27
  `${counts.blank} blank`].join("\n");
26
28
  }
@@ -1,10 +1,10 @@
1
1
  // A QR code from text or a link. PNG for chat, SVG for print. The quiet zone
2
2
  // and the size cap keep the code scannable at any setting.
3
- import { el, toast, download, bench, stack, panel, textArea, numberInput, notice, button, actions, toolcard } from "../core.js";
3
+ import { el, toast, download, downloadButton, bench, stack, panel, textArea, numberInput, notice, button, actions, toolcard } from "../core.js";
4
4
  import qrcode from "../vendor/qrcode-generator.mjs";
5
5
 
6
6
  export function mount(slot) {
7
- const content = textArea({ rows: 4, value: "https://mevebu.app", "aria-label": "Text or link", placeholder: "Text or link" });
7
+ const content = textArea({ rows: 4, "aria-label": "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: "h-auto w-[min(300px,100%)] [image-rendering:pixelated]", role: "img", "aria-label": "QR code" });
10
10
  const reading = el("p", { class: "border-s-[3px] border-transparent ps-2 text-sm text-ink-3" });
@@ -46,7 +46,7 @@ export function mount(slot) {
46
46
  }
47
47
  }
48
48
  svg = typeof code.createSVGTag === "function" ? code.createSVGTag(cell, false) : "";
49
- reading.textContent = `${modules} by ${modules} modules, medium error correction.`;
49
+ reading.textContent = "";
50
50
  } catch (caught) {
51
51
  error.write(caught?.message || "That text is too long for a QR code.");
52
52
  }
@@ -59,18 +59,20 @@ export function mount(slot) {
59
59
  render();
60
60
  });
61
61
 
62
+ // An empty field leaves a zero sized canvas, so a save would write an empty
63
+ // file and a copy would put nothing on the clipboard.
64
+ const noCode = () => !content.value.trim();
65
+
62
66
  const pngButton = button("Save PNG", { icon: "download", kind: "fill" });
63
- const svgButton = button("Save SVG", { icon: "download" });
67
+ const svgButton = downloadButton(() => svg, "qr.svg", "Save SVG", "image/svg+xml");
64
68
  const copyButton = button("Copy image", { icon: "copy" });
65
69
 
66
70
  pngButton.addEventListener("click", () => {
71
+ if (noCode()) return toast("Nothing to save yet");
67
72
  canvas.toBlob((blob) => blob && download("qr.png", blob, "image/png"));
68
73
  });
69
- svgButton.addEventListener("click", () => {
70
- if (!svg) return toast("Type something first");
71
- download("qr.svg", svg, "image/svg+xml");
72
- });
73
74
  copyButton.addEventListener("click", () => {
75
+ if (noCode()) return toast("Nothing to copy yet");
74
76
  canvas.toBlob(async (blob) => {
75
77
  try {
76
78
  await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
@@ -1,7 +1,7 @@
1
1
  // Reads a QR code from the camera or from an image file. The decoder loads
2
2
  // once and stays in memory. The camera stops when the page changes or a code
3
3
  // is found. Nothing leaves the tab at any point.
4
- import { el, icon, toast, copyText, bench, stack, panel, notice, button, output, actions, toolcard } from "../core.js";
4
+ import { el, icon, toast, bench, stack, panel, notice, button, copyButton, output, actions, toolcard } from "../core.js";
5
5
 
6
6
  // jsQR ships as a UMD bundle, so it has to load as a classic script and be read
7
7
  // off the window. Importing it as a module yields an empty namespace.
@@ -32,9 +32,9 @@ export function mount(slot) {
32
32
  const paint = scratch.getContext("2d", { willReadFrequently: true });
33
33
 
34
34
  const startButton = button("Start camera", { icon: "camera", kind: "fill" });
35
- const stopButton = el("button", { class: "inline-flex min-h-[var(--ctl)] cursor-pointer items-center justify-center gap-2 rounded-r1 border px-4 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 border-orbit bg-deck text-ink hover:border-orbit-hard hover:bg-deck-2", type: "button", hidden: true }, icon("video-off"), el("span", {}, "Stop"));
35
+ const stopButton = button("Stop", { icon: "video-off" });
36
+ stopButton.hidden = true;
36
37
  const uploadButton = button("Choose an image", { icon: "upload" });
37
- const copyButton = button("Copy", { icon: "copy" });
38
38
  const openWrap = el("span", { class: "flex min-w-0 flex-wrap items-center gap-2" });
39
39
 
40
40
  const drop = el(
@@ -50,8 +50,7 @@ export function mount(slot) {
50
50
  error.write("");
51
51
  openWrap.replaceChildren();
52
52
  if (/^https?:\/\//i.test(text.trim())) {
53
- openWrap.append(el("a", { class: "inline-flex min-h-[var(--ctl)] cursor-pointer items-center justify-center gap-2 rounded-r1 border px-4 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 border-orbit bg-deck text-ink hover:border-orbit-hard hover:bg-deck-2", href: text.trim(), target: "_blank", rel: "noopener" },
54
- icon("external-link"), el("span", {}, "Open link")));
53
+ openWrap.append(button("Open link", { tag: "a", href: text.trim(), target: "_blank", rel: "noopener", icon: "external-link" }));
55
54
  }
56
55
  toast("Found");
57
56
  }
@@ -74,8 +73,15 @@ export function mount(slot) {
74
73
  async function tick() {
75
74
  if (!live) return;
76
75
  if (video.readyState === video.HAVE_ENOUGH_DATA && video.videoWidth) {
77
- const hit = await scan(video.videoWidth, video.videoHeight, () =>
78
- paint.drawImage(video, 0, 0, video.videoWidth, video.videoHeight));
76
+ let hit = false;
77
+ try {
78
+ hit = await scan(video.videoWidth, video.videoHeight, () =>
79
+ paint.drawImage(video, 0, 0, video.videoWidth, video.videoHeight));
80
+ } catch {
81
+ stop();
82
+ error.write("The decoder did not load. Try again, or read an image instead.");
83
+ return;
84
+ }
79
85
  if (hit) {
80
86
  stop();
81
87
  return;
@@ -167,11 +173,6 @@ export function mount(slot) {
167
173
  delete drop.dataset.drag;
168
174
  if (event.dataTransfer.files[0]) scanFile(event.dataTransfer.files[0]);
169
175
  });
170
- copyButton.addEventListener("click", async () => {
171
- if (!found.textContent) return toast("Nothing to copy yet");
172
- toast((await copyText(found.textContent)) ? "Copied" : "Copy failed");
173
- });
174
-
175
176
  status.textContent = "Camera off.";
176
177
 
177
178
  const card = toolcard({
@@ -180,7 +181,7 @@ export function mount(slot) {
180
181
  stack(video, drop, fileField, status, error),
181
182
  panel("Decoded", found)
182
183
  ),
183
- foot: actions(openWrap, copyButton)});
184
+ foot: actions(openWrap, copyButton(() => found.textContent, "Copy"))});
184
185
  slot.append(card.node);
185
186
 
186
187
  return { unmount: stop };
@@ -1,10 +1,10 @@
1
1
  // Drops repeated lines and keeps the first time each one appears.
2
- import { bench, stack, panel, textArea, checkbox, notice, copyButton, actions, toolcard } from "../core.js";
2
+ import { bench, stack, panel, textArea, output, checkbox, notice, copyButton, actions, toolcard } from "../core.js";
3
3
 
4
4
  export function mount(slot) {
5
5
  const source = textArea({ rows: 12, "aria-label": "Text", placeholder: "Paste your list, one entry per line" });
6
6
  const ignoreCase = checkbox("Ignore case", { checked: false });
7
- const result = textArea({ rows: 12, readonly: true, "aria-label": "Result" });
7
+ const result = output({ tag: "textarea", rows: 12 });
8
8
  const note = notice("note");
9
9
 
10
10
  function run() {
@@ -24,7 +24,7 @@ export function mount(slot) {
24
24
  else { seen.add(key); kept.push(line); }
25
25
  }
26
26
  result.value = kept.join("\n");
27
- note.write(removed ? `${removed} lines removed.` : "No duplicate lines found.");
27
+ note.write(removed ? `${removed} line${removed === 1 ? "" : "s"} removed.` : "No duplicate lines found.");
28
28
  }
29
29
 
30
30
  source.addEventListener("input", run);
@@ -1,10 +1,10 @@
1
1
  // Tidies spacing in pasted text: collapses runs of spaces, trims each line and
2
2
  // optionally drops blank lines.
3
- import { bench, stack, panel, textArea, toggleRow, copyButton, actions, toolcard } from "../core.js";
3
+ import { bench, stack, panel, textArea, output, toggleRow, copyButton, actions, toolcard } from "../core.js";
4
4
 
5
5
  export function mount(slot) {
6
- const source = textArea({ rows: 12, "aria-label": "Text", placeholder: "Paste your text" });
7
- const result = textArea({ rows: 12, readonly: true, "aria-label": "Result" });
6
+ const source = textArea({ rows: 12, "aria-label": "Text" });
7
+ const result = output({ tag: "textarea", rows: 12 });
8
8
  const options = toggleRow([
9
9
  { value: "collapse", label: "Collapse spaces", checked: true },
10
10
  { value: "trim", label: "Trim lines", checked: true },
@@ -1,9 +1,9 @@
1
1
  // Sorts a list of lines alphabetically or numerically, either direction.
2
- import { bench, stack, panel, textArea, chipRow, toggleRow, copyButton, actions, toolcard } from "../core.js";
2
+ import { bench, stack, panel, textArea, output, chipRow, toggleRow, copyButton, actions, toolcard } from "../core.js";
3
3
 
4
4
  export function mount(slot) {
5
5
  const source = textArea({ rows: 12, "aria-label": "Text", placeholder: "Paste your list, one entry per line" });
6
- const result = textArea({ rows: 12, readonly: true, "aria-label": "Result" });
6
+ const result = output({ tag: "textarea", rows: 12 });
7
7
 
8
8
  let direction = "asc";
9
9
  const dirChips = chipRow(
@@ -39,7 +39,7 @@ export function mount(slot) {
39
39
 
40
40
  const card = toolcard({
41
41
  body: bench(
42
- stack(panel("Direction", dirChips), options),
42
+ stack(panel("Text", source), panel("Direction", dirChips), options),
43
43
  stack(panel("Result", result))
44
44
  ),
45
45
  foot: actions(copyButton(() => result.value, "Copy result", "fill"))});
@@ -1,6 +1,6 @@
1
1
  // Reverses text by character, word or line. Character mode keeps emoji and
2
2
  // accented letters whole when the browser exposes grapheme segmentation.
3
- import { bench, stack, panel, textArea, chipRow, copyButton, actions, toolcard } from "../core.js";
3
+ import { bench, stack, panel, textArea, output, chipRow, copyButton, actions, toolcard } from "../core.js";
4
4
 
5
5
  function reverseChars(text) {
6
6
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
@@ -14,8 +14,8 @@ const reverseWords = (text) => text.trim() ? text.trim().split(/\s+/).reverse().
14
14
  const reverseLines = (text) => text ? text.split("\n").reverse().join("\n") : "";
15
15
 
16
16
  export function mount(slot) {
17
- const source = textArea({ rows: 10, "aria-label": "Text", placeholder: "Type or paste text" });
18
- const result = textArea({ rows: 10, readonly: true, "aria-label": "Result" });
17
+ const source = textArea({ rows: 10, "aria-label": "Text" });
18
+ const result = output({ tag: "textarea", rows: 10 });
19
19
 
20
20
  let mode = "chars";
21
21
  const chips = chipRow(
@@ -15,8 +15,10 @@ const countSentences = (text) => text.trim()
15
15
  ? text.trim().split(/[.!?\u2026]+/).filter((part) => part.trim()).length
16
16
  : 0;
17
17
 
18
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
19
+
18
20
  export function mount(slot) {
19
- const source = textArea({ rows: 10, "aria-label": "Text", placeholder: "Type or paste text" });
21
+ const source = textArea({ rows: 10, "aria-label": "Text" });
20
22
  const limitField = numberInput({ min: "0", placeholder: "No limit", "aria-label": "Word limit" });
21
23
 
22
24
  const wordsTile = tile("Words");
@@ -34,10 +36,10 @@ export function mount(slot) {
34
36
 
35
37
  function summary() {
36
38
  return [
37
- `${counts.words} words`,
38
- `${counts.chars} characters`,
39
- `${counts.sentences} sentences`,
40
- `${counts.lines} lines`].join("\n");
39
+ plural(counts.words, "word"),
40
+ plural(counts.chars, "character"),
41
+ plural(counts.sentences, "sentence"),
42
+ plural(counts.lines, "line")].join("\n");
41
43
  }
42
44
 
43
45
  function run() {
@@ -55,11 +57,13 @@ export function mount(slot) {
55
57
  readingTile.set(counts.words ? `${Math.max(1, Math.round(counts.words / WPM))} min` : "0 min");
56
58
 
57
59
  const limit = parseInt(limitField.value, 10) || 0;
60
+ // The track is only worth showing once a limit exists to fill it.
61
+ meter.hidden = limit <= 0;
58
62
  if (limit > 0) {
59
63
  const ratio = words / limit;
60
64
  fill.style.width = `${Math.min(100, ratio * 100)}%`;
61
65
  fill.dataset.level = ratio > 1 ? "weak" : ratio > 0.8 ? "ok" : "strong";
62
- limitNote.write(ratio > 1 ? `${words - limit} over the limit.` : `${limit - words} words left.`);
66
+ limitNote.write(ratio > 1 ? `${words - limit} over the limit.` : `${plural(limit - words, "word")} left.`);
63
67
  } else {
64
68
  fill.style.width = "0%";
65
69
  delete fill.dataset.level;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Small tools that run in your browser. No accounts, no uploads, no tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",