mevebu 1.11.0 → 2.1.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.
@@ -0,0 +1,203 @@
1
+ // Split a list into balanced groups. Two modes: a fixed number of groups, or a
2
+ // fixed size per group. Exclusion pairs keep two people apart, and leads are
3
+ // spread one per group.
4
+ import {
5
+ el, toast, download, bench, stack, panel, textArea, numberInput, checkbox,
6
+ button, actions, toolcard, blank, notice, chipRow, copyButton,
7
+ } from "../core.js";
8
+
9
+ function shuffle(list) {
10
+ const out = [...list];
11
+ for (let i = out.length - 1; i > 0; i--) {
12
+ const j = Math.floor(Math.random() * (i + 1));
13
+ [out[i], out[j]] = [out[j], out[i]];
14
+ }
15
+ return out;
16
+ }
17
+
18
+ function parseNames(text) {
19
+ return text
20
+ .split(/\r?\n/)
21
+ .map((line) => line.trim())
22
+ .filter(Boolean);
23
+ }
24
+
25
+ function parsePairs(text) {
26
+ return text
27
+ .split(/\r?\n/)
28
+ .map((line) => line.split(/\s*(?:\||,|;|\+)\s*/).map((part) => part.trim()).filter(Boolean))
29
+ .filter((pair) => pair.length === 2);
30
+ }
31
+
32
+ function violates(groups, pairs) {
33
+ const where = new Map();
34
+ groups.forEach((group, index) => group.forEach((name) => where.set(name, index)));
35
+ return pairs.some(([a, b]) => where.has(a) && where.has(b) && where.get(a) === where.get(b));
36
+ }
37
+
38
+ function build(names, mode, value, pairs, leads, attempts = 400) {
39
+ if (!names.length) return null;
40
+ let best = null;
41
+ for (let round = 0; round < attempts; round++) {
42
+ const pool = shuffle(names);
43
+ let groups;
44
+ if (mode === "groups") {
45
+ const count = Math.max(1, Math.min(value, names.length));
46
+ groups = Array.from({ length: count }, () => []);
47
+ pool.forEach((name, index) => groups[index % count].push(name));
48
+ } else {
49
+ const size = Math.max(1, value);
50
+ groups = [];
51
+ for (let i = 0; i < pool.length; i += size) groups.push(pool.slice(i, i + size));
52
+ }
53
+ if (pairs.length && violates(groups, pairs)) {
54
+ best ??= groups;
55
+ continue;
56
+ }
57
+ return groups;
58
+ }
59
+ return best;
60
+ }
61
+
62
+ function placeLeads(groups, leads) {
63
+ if (!leads.length) return groups;
64
+ const pool = [...leads];
65
+ // One lead per group first, then any leftover leads join the smallest group.
66
+ for (const group of groups) {
67
+ if (!pool.length) break;
68
+ const lead = pool.shift();
69
+ if (group.includes(lead)) continue;
70
+ const at = group.findIndex((name) => !leads.includes(name));
71
+ if (at >= 0) group[at] = lead;
72
+ else group.push(lead);
73
+ }
74
+ for (const lead of pool) {
75
+ const smallest = groups.reduce((a, b) => (a.length <= b.length ? a : b));
76
+ if (!smallest.includes(lead)) smallest.push(lead);
77
+ }
78
+ return groups;
79
+ }
80
+
81
+ export function mount(slot) {
82
+ const names = textArea({ rows: 8, placeholder: "Ani\nBudi\nCitra\nDewi", "aria-label": "Names" });
83
+ const leads = textArea({ rows: 3, placeholder: "Ani\nBudi", "aria-label": "Leads" });
84
+ const exclusions = textArea({ rows: 3, placeholder: "Ani, Budi", "aria-label": "Exclusion pairs" });
85
+
86
+ let mode = "groups";
87
+ const modeRow = chipRow(
88
+ [
89
+ { value: "groups", label: "Number of groups" },
90
+ { value: "size", label: "Size per group" },
91
+ ],
92
+ { value: "groups", label: "Split by", onChange: (value) => { mode = value; sync(); } }
93
+ );
94
+ const amount = numberInput({ min: "1", max: "200", step: "1", value: "4", "aria-label": "Group count or size" });
95
+ const amountLabel = el("span", { class: "text-sm font-semibold text-ink-2" }, "Groups");
96
+ const keepLeads = checkbox("One lead per group", { checked: true });
97
+
98
+ const error = notice("warn");
99
+ const summary = el("p", { class: "text-sm text-ink-2" });
100
+ const output = el("div", { class: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))]" });
101
+ const empty = blank("users-round", "Paste names to build the groups.");
102
+
103
+ const generate = button("Generate", { icon: "shuffle", kind: "fill" });
104
+ const copyAll = copyButton(() => summary.dataset.plain ?? "", "Copy groups");
105
+
106
+ let groups = [];
107
+
108
+ function sync() {
109
+ amountLabel.textContent = mode === "groups" ? "Groups" : "People per group";
110
+ }
111
+
112
+ function render() {
113
+ empty.hidden = groups.length > 0;
114
+ output.hidden = groups.length === 0;
115
+ copyAll.disabled = groups.length === 0;
116
+ output.replaceChildren(
117
+ ...groups.map((group, index) =>
118
+ el(
119
+ "section",
120
+ { class: "grid content-start gap-2 rounded-r2 border border-orbit bg-deck-2 p-3.5" },
121
+ el("h3", { class: "m-0 flex items-baseline justify-between gap-2 text-sm font-bold" },
122
+ el("span", {}, `Group ${index + 1}`),
123
+ el("span", { class: "text-2xs font-semibold text-ink-3 tabular-nums" }, `${group.length}`)),
124
+ el("ul", { class: "m-0 grid list-none gap-1 p-0" },
125
+ ...group.map((name) =>
126
+ el("li", { class: "flex items-center gap-1.5 text-sm text-ink-2" },
127
+ el("span", { class: "size-1.5 flex-none rounded-full bg-jade" }), name)))
128
+ )
129
+ )
130
+ );
131
+ }
132
+
133
+ function run() {
134
+ const list = parseNames(names.value);
135
+ const leadList = keepLeads.input.checked ? parseNames(leads.value).filter((name) => list.includes(name)) : [];
136
+ const pairs = parsePairs(exclusions.value).filter(([a, b]) => list.includes(a) && list.includes(b));
137
+ error.write("");
138
+
139
+ if (!list.length) {
140
+ groups = [];
141
+ summary.textContent = "";
142
+ render();
143
+ return;
144
+ }
145
+ if (list.length !== new Set(list).size) error.write("A name appears more than once, so the count is off.");
146
+ if (leadList.length && mode === "groups" && leadList.length > Number(amount.value)) {
147
+ error.write("There are more leads than groups, so some groups get two.");
148
+ }
149
+
150
+ groups = build(list, mode, Math.max(1, Number(amount.value) || 1), pairs, leadList) ?? [];
151
+ if (keepLeads.input.checked) groups = placeLeads(groups, leadList);
152
+
153
+ const sizes = groups.map((group) => group.length);
154
+ const spread = sizes.length ? Math.max(...sizes) - Math.min(...sizes) : 0;
155
+ summary.textContent = `${list.length} people · ${groups.length} group${groups.length === 1 ? "" : "s"} · ${Math.min(...sizes)} to ${Math.max(...sizes)} each${spread > 1 ? ", uneven because of the rules" : ""}`;
156
+ summary.dataset.plain = groups.map((group, index) => `Group ${index + 1}: ${group.join(", ")}`).join("\n");
157
+ render();
158
+ }
159
+
160
+ generate.addEventListener("click", () => {
161
+ run();
162
+ if (groups.length) toast("Groups ready");
163
+ });
164
+ amount.addEventListener("input", run);
165
+ keepLeads.input.addEventListener("change", run);
166
+ names.addEventListener("input", run);
167
+
168
+ const downloadButton = button("Download CSV", {
169
+ icon: "download",
170
+ onclick: () => {
171
+ if (!groups.length) return;
172
+ const rows = [["group", "name"]];
173
+ groups.forEach((group, index) => group.forEach((name) => rows.push([String(index + 1), name])));
174
+ const csv = rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")).join("\n");
175
+ download("groups.csv", csv, "text/csv");
176
+ },
177
+ });
178
+
179
+ sync();
180
+ run();
181
+
182
+ const shell = toolcard({
183
+ body: bench(
184
+ stack(
185
+ panel("Names", names, "One per line."),
186
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Split by"), modeRow),
187
+ panel(amountLabel, amount),
188
+ panel("Leads", leads, "Optional. One per line."),
189
+ keepLeads,
190
+ panel("Keep apart", exclusions, "One pair per line, like Ani, Budi."),
191
+ error
192
+ ),
193
+ stack(summary, empty, output)
194
+ ),
195
+ foot: actions(copyAll, downloadButton, generate),
196
+ });
197
+
198
+ slot.append(shell.node);
199
+
200
+ return {};
201
+ }
202
+
203
+ export default { mount };
@@ -0,0 +1,191 @@
1
+ // A dated series with a trend, a season and noise, so a demo chart looks like
2
+ // data instead of a random walk. Seeded, and the shape is previewed before it
3
+ // is downloaded.
4
+ import {
5
+ el, toast, download, bench, stack, panel, numberInput, textInput, selectInput,
6
+ button, actions, toolcard, notice,
7
+ } from "../core.js";
8
+ import { toCsv } from "../csv.js";
9
+ import { seeded, gaussian } from "../random.js";
10
+
11
+ const FREQUENCIES = [
12
+ { value: "hour", label: "Hourly" },
13
+ { value: "day", label: "Daily" },
14
+ { value: "week", label: "Weekly" },
15
+ { value: "month", label: "Monthly" },
16
+ ];
17
+
18
+ function step(date, frequency) {
19
+ const next = new Date(date);
20
+ if (frequency === "hour") next.setHours(next.getHours() + 1);
21
+ else if (frequency === "day") next.setDate(next.getDate() + 1);
22
+ else if (frequency === "week") next.setDate(next.getDate() + 7);
23
+ else next.setMonth(next.getMonth() + 1);
24
+ return next;
25
+ }
26
+
27
+ function iso(date, frequency) {
28
+ const year = date.getFullYear();
29
+ const month = String(date.getMonth() + 1).padStart(2, "0");
30
+ const day = String(date.getDate()).padStart(2, "0");
31
+ if (frequency === "hour") return `${year}-${month}-${day} ${String(date.getHours()).padStart(2, "0")}:00`;
32
+ return `${year}-${month}-${day}`;
33
+ }
34
+
35
+ export function mount(slot) {
36
+ const start = el("input", {
37
+ class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 text-base focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
38
+ type: "date",
39
+ value: (() => {
40
+ const now = new Date();
41
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
42
+ })(),
43
+ "aria-label": "Start date",
44
+ });
45
+ const frequency = selectInput(FREQUENCIES, { value: "day", "aria-label": "Frequency" });
46
+ const length = numberInput({ min: "2", max: "2000", step: "1", value: "60", "aria-label": "Points" });
47
+ const base = numberInput({ value: "100", step: "1", "aria-label": "Base value" });
48
+ const trend = numberInput({ value: "0.4", step: "0.1", "aria-label": "Trend per point" });
49
+ const season = numberInput({ value: "12", step: "1", "aria-label": "Season amplitude" });
50
+ const period = numberInput({ min: "2", max: "365", step: "1", value: "7", "aria-label": "Season period" });
51
+ const noise = numberInput({ min: "0", max: "100", step: "0.5", value: "4", "aria-label": "Noise" });
52
+ const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
53
+ const spikeDate = el("input", {
54
+ class: "w-full min-h-[var(--ctl)] rounded-r1 border border-orbit bg-deck px-3 py-2.5 text-base focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
55
+ type: "date",
56
+ "aria-label": "Spike date",
57
+ });
58
+ const spikeMultiplier = numberInput({ value: "2", step: "0.1", "aria-label": "Spike multiplier" });
59
+
60
+ const error = notice("warn");
61
+ const summary = el("p", { class: "text-sm text-ink-2" });
62
+ const chart = el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-3" });
63
+ const result = el("pre", { class: "m-0 max-h-56 overflow-auto rounded-r2 border border-orbit bg-deck-2 p-3 font-mono text-2xs leading-[1.6]" });
64
+
65
+ let series = [];
66
+
67
+ function build() {
68
+ const count = Math.max(2, Math.min(2000, Number(length.value) || 2));
69
+ const random = seeded(seed.value || "mevebu");
70
+ const amp = Number(season.value) || 0;
71
+ const slope = Number(trend.value) || 0;
72
+ const spread = Number(noise.value) || 0;
73
+ const wave = Math.max(2, Number(period.value) || 7);
74
+ const startDate = new Date(`${start.value}T00:00:00`);
75
+ if (Number.isNaN(startDate.getTime())) {
76
+ error.write("Pick a start date.");
77
+ return;
78
+ }
79
+
80
+ const out = [];
81
+ let date = startDate;
82
+ for (let i = 0; i < count; i++) {
83
+ const seasonal = amp * Math.sin((2 * Math.PI * i) / wave);
84
+ const drift = slope * i;
85
+ const jitter = spread ? gaussian(random, 0, spread) : 0;
86
+ out.push({ date: iso(date, frequency.value), value: Number((Number(base.value || 0) + drift + seasonal + jitter).toFixed(2)) });
87
+ date = step(date, frequency.value);
88
+ }
89
+
90
+ if (spikeDate.value && Number(spikeMultiplier.value)) {
91
+ const target = spikeDate.value;
92
+ const hit = out.find((point) => point.date.startsWith(target));
93
+ if (hit) hit.value = Number((hit.value * Number(spikeMultiplier.value)).toFixed(2));
94
+ else error.write("The spike date is outside the series, so it was skipped.");
95
+ }
96
+
97
+ series = out;
98
+ render();
99
+ }
100
+
101
+ function render() {
102
+ if (!series.length) {
103
+ summary.textContent = "";
104
+ chart.replaceChildren();
105
+ result.textContent = "";
106
+ return;
107
+ }
108
+ const values = series.map((point) => point.value);
109
+ const min = Math.min(...values);
110
+ const max = Math.max(...values);
111
+ const span = max - min || 1;
112
+ const width = 100;
113
+ const height = 34;
114
+
115
+ // One polyline, drawn in a fixed viewBox, so the preview scales without a
116
+ // chart library.
117
+ const points = series
118
+ .map((point, index) => {
119
+ const x = (index / Math.max(1, series.length - 1)) * width;
120
+ const y = height - ((point.value - min) / span) * height;
121
+ return `${x.toFixed(2)},${y.toFixed(2)}`;
122
+ })
123
+ .join(" ");
124
+
125
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
126
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
127
+ svg.setAttribute("preserveAspectRatio", "none");
128
+ svg.setAttribute("class", "h-40 w-full");
129
+ svg.setAttribute("role", "img");
130
+ svg.setAttribute("aria-label", `Series preview, ${series.length} points`);
131
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
132
+ line.setAttribute("points", points);
133
+ line.setAttribute("fill", "none");
134
+ line.setAttribute("stroke", "var(--jade)");
135
+ line.setAttribute("stroke-width", "0.7");
136
+ line.setAttribute("vector-effect", "non-scaling-stroke");
137
+ svg.append(line);
138
+
139
+ chart.replaceChildren(
140
+ svg,
141
+ el("div", { class: "flex justify-between text-2xs text-ink-3 tabular-nums" },
142
+ el("span", {}, series[0].date),
143
+ el("span", {}, `${min} to ${max}`),
144
+ el("span", {}, series[series.length - 1].date))
145
+ );
146
+
147
+ const preview = [["date", "value"], ...series.slice(0, 40).map((point) => [point.date, String(point.value)])];
148
+ result.textContent = toCsv(preview) + (series.length > 40 ? `\n… ${series.length - 40} more rows` : "");
149
+ summary.textContent = `${series.length} points · ${frequency.value} · ${min} to ${max} · seed ${seed.value || "mevebu"}`;
150
+ }
151
+
152
+ for (const node of [start, frequency, length, base, trend, season, period, noise, seed, spikeDate, spikeMultiplier]) {
153
+ node.addEventListener("input", build);
154
+ node.addEventListener("change", build);
155
+ }
156
+
157
+ const downloadButton = button("Download CSV", {
158
+ icon: "download",
159
+ kind: "fill",
160
+ onclick: () => {
161
+ if (!series.length) return;
162
+ download("series.csv", toCsv([["date", "value"], ...series.map((point) => [point.date, String(point.value)])]), "text/csv");
163
+ toast("Series ready");
164
+ },
165
+ });
166
+
167
+ build();
168
+
169
+ const shell = toolcard({
170
+ body: bench(
171
+ stack(
172
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Start", start), panel("Frequency", frequency)),
173
+ panel("Points", length),
174
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Base", base), panel("Trend per point", trend)),
175
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Season amplitude", season), panel("Season period", period)),
176
+ panel("Noise", noise),
177
+ panel("Seed", seed),
178
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Spike date", spikeDate), panel("Spike ×", spikeMultiplier)),
179
+ error
180
+ ),
181
+ stack(summary, chart, result)
182
+ ),
183
+ foot: actions(downloadButton),
184
+ });
185
+
186
+ slot.append(shell.node);
187
+
188
+ return {};
189
+ }
190
+
191
+ export default { mount };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "1.11.0",
3
+ "version": "2.1.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",
@@ -1,78 +0,0 @@
1
- // Heads or tails with a running count that survives a page change.
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: "wrap-anywhere text-xl font-bold tracking-[-0.02em] tabular-nums" }, "0");
11
- const row = el("div", { class: "grid gap-0.5 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3" }, value, el("span", { class: "text-2xs font-semibold text-ink-3" }, key));
12
- return { row, value };
13
- }
14
-
15
- export function mount(slot) {
16
- const PIN = "coin-flip";
17
- const saved = readPin(PIN, { heads: 0, tails: 0 });
18
- let heads = Number(saved.heads) || 0;
19
- let tails = Number(saved.tails) || 0;
20
- let timer = 0;
21
-
22
- const coin = el("div", { class: "grid size-32 select-none place-items-center rounded-full bg-jade text-display font-bold text-on-jade data-[toss]:animate-toss", role: "img", "aria-label": "Coin, not flipped yet" }, "?");
23
- const verdict = el("p", { class: "text-lg font-bold tracking-[-0.01em] 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: "grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))]" }, headsTile.row, tailsTile.row, totalTile.row);
28
-
29
- function render(decision) {
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);
35
- writePin(PIN, { heads, tails });
36
- }
37
-
38
- const flipButton = button("Flip", { kind: "fill" });
39
- const resetButton = button("Reset the count", { kind: "quiet" });
40
-
41
- flipButton.addEventListener("click", () => {
42
- clearTimeout(timer);
43
- delete coin.dataset.toss;
44
- void coin.offsetWidth;
45
- coin.dataset.toss = "";
46
- timer = setTimeout(() => {
47
- const isHeads = randomInt(2) === 0;
48
- if (isHeads) heads++;
49
- else tails++;
50
- coin.textContent = isHeads ? "H" : "T";
51
- render(isHeads ? "Heads" : "Tails");
52
- }, TOSS_MS);
53
- });
54
-
55
- resetButton.addEventListener("click", () => {
56
- clearTimeout(timer);
57
- heads = 0;
58
- tails = 0;
59
- coin.textContent = "?";
60
- render("");
61
- });
62
-
63
- const card = toolcard({
64
- body: bench(
65
- stack(panel("Coin", coin)),
66
- stack(panel("Result", verdict), stats)
67
- ),
68
- foot: actions(resetButton, flipButton)});
69
- slot.append(card.node);
70
- render("");
71
-
72
- return {
73
- unmount() {
74
- clearTimeout(timer);
75
- }};
76
- }
77
-
78
- export default { mount };
@@ -1,68 +0,0 @@
1
- // Draws one or more names from a list, every entry with equal odds.
2
- import { el, toast, copyText, shuffle, randomInt, debounce, bench, stack, panel, textArea, numberInput, checkbox, button, output, readPin, writePin, actions, toolcard } from "../core.js";
3
-
4
- export function mount(slot) {
5
- const PIN = "name-picker";
6
- const saved = readPin(PIN, { entries: "Ana\nBudi\nCitra\nDimas\nElena\nFajar\nGita\nHendra", winners: "1", unique: true });
7
-
8
- const listField = textArea({ value: saved.entries ?? "", rows: 8, "aria-label": "Entries, one per line", placeholder: "One entry per line" });
9
- const countField = numberInput({ value: saved.winners ?? "1", min: "1", max: "500", "aria-label": "How many to draw" });
10
- const uniqueBox = checkbox("No repeats", { checked: saved.unique !== false });
11
- const notify = el("p", { class: "border-s-[3px] border-transparent ps-2 text-sm text-ink-3" });
12
-
13
- const drawButton = button("Draw", { icon: "play", kind: "fill" });
14
- const copyButton = button("Copy", { icon: "copy" });
15
- const result = output();
16
- let current = [];
17
-
18
- function entries() {
19
- const raw = listField.value.includes(",") && !listField.value.includes("\n")
20
- ? listField.value.split(",")
21
- : listField.value.split(/\n+/);
22
- return raw.map((line) => line.trim()).filter(Boolean);
23
- }
24
-
25
- function draw(syncInput = false) {
26
- const list = entries();
27
- if (!list.length) {
28
- current = [];
29
- result.write("");
30
- notify.textContent = "Add entries to draw.";
31
- return;
32
- }
33
- const wants = Math.max(1, parseInt(countField.value || "1", 10) || 1);
34
- const total = uniqueBox.input.checked ? Math.min(wants, list.length) : Math.min(wants, 500);
35
- if (syncInput) countField.value = String(total);
36
- current = uniqueBox.input.checked
37
- ? shuffle(list).slice(0, total)
38
- : Array.from({ length: total }, () => list[randomInt(list.length)]);
39
- result.write(current.join("\n"));
40
- notify.textContent = `${current.length} drawn from ${list.length}.`;
41
- writePin(PIN, { entries: listField.value, winners: String(current.length), unique: uniqueBox.input.checked });
42
- }
43
-
44
- // Editing the list, the count or the repeat rule redraws right away. The
45
- // button stays for a fresh draw with the same settings.
46
- drawButton.addEventListener("click", () => draw(true));
47
- copyButton.addEventListener("click", async () => {
48
- if (!current.length) return toast("Draw first, then copy");
49
- toast((await copyText(result.textContent)) ? "Copied" : "Copy failed");
50
- });
51
- listField.addEventListener("input", debounce(() => draw(false), 200));
52
- countField.addEventListener("input", () => draw(false));
53
- countField.addEventListener("change", () => draw(true));
54
- uniqueBox.input.addEventListener("change", () => draw(true));
55
-
56
- const card = toolcard({
57
- body: bench(
58
- stack(panel("Entries", listField), panel("Winners", countField), panel(null, uniqueBox)),
59
- stack(panel("Drawn", result), notify)
60
- ),
61
- foot: actions(copyButton, drawButton)});
62
- slot.append(card.node);
63
- draw();
64
-
65
- return {};
66
- }
67
-
68
- export default { mount };