mevebu 1.11.0 → 2.0.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,204 @@
1
+ // The document a dataset needs before anyone uses it: one row per column, with
2
+ // the type, the null count, the distinct count, the range and a sample.
3
+ import {
4
+ el, toast, download, bench, stack, panel, button, actions, toolcard, blank,
5
+ notice, output,
6
+ } from "../core.js";
7
+ import { dropzone, baseName, formatBytes } from "../files.js";
8
+ import { parseDelimited, detectDelimiter, headerKeys, toCsv } from "../csv.js";
9
+
10
+ const TYPE_LABELS = {
11
+ number: "number",
12
+ date: "date",
13
+ boolean: "boolean",
14
+ text: "text",
15
+ empty: "empty",
16
+ };
17
+
18
+ function guessType(values) {
19
+ const sample = values.filter((value) => value !== "");
20
+ if (!sample.length) return "empty";
21
+ if (sample.every((value) => /^-?\d+(\.\d+)?$/.test(value))) return "number";
22
+ if (sample.every((value) => /^\d{4}-\d{2}-\d{2}$/.test(value))) return "date";
23
+ if (sample.every((value) => /^(true|false)$/i.test(value))) return "boolean";
24
+ return "text";
25
+ }
26
+
27
+ function summarise(name, values) {
28
+ const filled = values.filter((value) => value !== "");
29
+ const nulls = values.length - filled.length;
30
+ const distinct = new Set(filled).size;
31
+ const type = guessType(filled);
32
+
33
+ let min = "";
34
+ let max = "";
35
+ if (type === "number") {
36
+ const numbers = filled.map(Number);
37
+ min = String(Math.min(...numbers));
38
+ max = String(Math.max(...numbers));
39
+ } else if (type === "date" || type === "text") {
40
+ const sorted = [...filled].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
41
+ min = sorted[0] ?? "";
42
+ max = sorted[sorted.length - 1] ?? "";
43
+ }
44
+
45
+ const counts = new Map();
46
+ for (const value of filled) counts.set(value, (counts.get(value) ?? 0) + 1);
47
+ const top = [...counts].sort((a, b) => b[1] - a[1])[0];
48
+
49
+ return {
50
+ name,
51
+ type,
52
+ nulls,
53
+ distinct,
54
+ min,
55
+ max,
56
+ sample: filled[0] ?? "",
57
+ top: top ? `${top[0]} (${top[1]})` : "",
58
+ };
59
+ }
60
+
61
+ export function mount(slot) {
62
+ let file = null;
63
+ let header = [];
64
+ let rows = [];
65
+ let summaryRows = [];
66
+
67
+ const error = notice("warn");
68
+ const summary = el("p", { class: "text-sm text-ink-2" });
69
+ const table = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
70
+ const markdown = output({ rows: 12 });
71
+ const empty = blank("table", "Drop a CSV to describe it.");
72
+
73
+ const drop = dropzone({ label: "Drop a CSV here", accept: ".csv,text/csv,text/plain", iconName: "table" });
74
+ const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
75
+ const copyButton = button("Copy Markdown", {
76
+ icon: "copy",
77
+ onclick: async () => {
78
+ if (!markdown.textContent) return;
79
+ try {
80
+ await navigator.clipboard.writeText(markdown.textContent);
81
+ toast("Dictionary copied");
82
+ } catch {
83
+ error.write("The clipboard is blocked here. Download the file instead.");
84
+ }
85
+ },
86
+ });
87
+ const downloadMarkdown = button("Download .md", {
88
+ icon: "download",
89
+ onclick: () => {
90
+ if (!markdown.textContent) return;
91
+ download(`${baseName(file?.name || "data")}-dictionary.md`, markdown.textContent, "text/markdown");
92
+ },
93
+ });
94
+ const downloadCsv = button("Download CSV", { icon: "download", kind: "fill" });
95
+
96
+ function renderTable() {
97
+ empty.hidden = summaryRows.length > 0;
98
+ table.hidden = summaryRows.length === 0;
99
+ markdown.hidden = summaryRows.length === 0;
100
+ if (!summaryRows.length) {
101
+ table.replaceChildren();
102
+ markdown.write("");
103
+ summary.textContent = "";
104
+ return;
105
+ }
106
+
107
+ const columns = ["Column", "Type", "Nulls", "Distinct", "Min", "Max", "Sample"];
108
+ const head = el("div", {
109
+ class: "grid gap-3 border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase [grid-template-columns:minmax(90px,1fr)_70px_52px_64px_minmax(70px,.9fr)_minmax(70px,.9fr)_minmax(90px,1.1fr)]",
110
+ }, ...columns.map((cell) => el("span", {}, cell)));
111
+
112
+ const body = summaryRows.map((row) =>
113
+ el("div", {
114
+ class: "grid gap-3 border-b border-orbit px-3 py-2 text-sm last:border-b-0 [grid-template-columns:minmax(90px,1fr)_70px_52px_64px_minmax(70px,.9fr)_minmax(70px,.9fr)_minmax(90px,1.1fr)]",
115
+ },
116
+ el("span", { class: "truncate font-semibold" }, row.name),
117
+ el("span", { class: "text-2xs text-ink-3" }, TYPE_LABELS[row.type] ?? row.type),
118
+ el("span", { class: "font-mono text-2xs tabular-nums" }, String(row.nulls)),
119
+ el("span", { class: "font-mono text-2xs tabular-nums" }, String(row.distinct)),
120
+ el("span", { class: "truncate font-mono text-2xs" }, row.min),
121
+ el("span", { class: "truncate font-mono text-2xs" }, row.max),
122
+ el("span", { class: "truncate font-mono text-2xs text-ink-3" }, row.sample))
123
+ );
124
+
125
+ table.replaceChildren(head, ...body);
126
+ }
127
+
128
+ function renderMarkdown() {
129
+ const lines = [
130
+ `| Column | Type | Nulls | Distinct | Min | Max | Sample |`,
131
+ `| --- | --- | ---: | ---: | --- | --- | --- |`,
132
+ ...summaryRows.map((row) =>
133
+ `| ${row.name} | ${row.type} | ${row.nulls} | ${row.distinct} | ${row.min} | ${row.max} | ${row.sample} |`),
134
+ ];
135
+ markdown.write(lines.join("\n"));
136
+ }
137
+
138
+ function run() {
139
+ summaryRows = header.map((name, index) => summarise(name, rows.map((row) => row[index] ?? "")));
140
+ const nulls = summaryRows.reduce((total, row) => total + row.nulls, 0);
141
+ summary.textContent = `${rows.length} row${rows.length === 1 ? "" : "s"} · ${header.length} column${header.length === 1 ? "" : "s"} · ${nulls} empty cell${nulls === 1 ? "" : "s"}`;
142
+ renderTable();
143
+ renderMarkdown();
144
+ }
145
+
146
+ async function open(files) {
147
+ const picked = Array.from(files).find((entry) => /\.(csv|tsv|txt)$/i.test(entry.name) || entry.type.startsWith("text/"));
148
+ if (!picked) {
149
+ error.write("That file is not a CSV.");
150
+ return;
151
+ }
152
+ try {
153
+ const text = await picked.text();
154
+ const parsed = parseDelimited(text, detectDelimiter(text));
155
+ if (parsed.length < 2) throw new Error("A CSV needs a header row and at least one data row.");
156
+ file = picked;
157
+ header = headerKeys(parsed[0]);
158
+ rows = parsed.slice(1).filter((row) => row.some((cell) => cell !== ""));
159
+ error.write("");
160
+ run();
161
+ } catch (caught) {
162
+ error.write(caught?.message || "This file could not be read.");
163
+ }
164
+ }
165
+
166
+ drop.onFiles(open);
167
+ clearButton.addEventListener("click", () => {
168
+ file = null;
169
+ header = [];
170
+ rows = [];
171
+ summaryRows = [];
172
+ error.write("");
173
+ renderTable();
174
+ });
175
+ downloadCsv.addEventListener("click", () => {
176
+ if (!summaryRows.length) return;
177
+ const csv = toCsv([
178
+ ["column", "type", "nulls", "distinct", "min", "max", "sample", "top"],
179
+ ...summaryRows.map((row) => [row.name, row.type, row.nulls, row.distinct, row.min, row.max, row.sample, row.top]),
180
+ ]);
181
+ download(`${baseName(file?.name || "data")}-dictionary.csv`, csv, "text/csv");
182
+ });
183
+
184
+ renderTable();
185
+
186
+ const shell = toolcard({
187
+ body: bench(
188
+ stack(
189
+ drop,
190
+ panel("What it reports", el("p", { class: "m-0 text-sm leading-[1.6] text-ink-3" },
191
+ "The type it detects, empty cells, distinct values, the smallest and largest value, and one sample. A type can be wrong on a mixed column, so check it before you publish.")),
192
+ error
193
+ ),
194
+ stack(summary, empty, table, markdown)
195
+ ),
196
+ foot: actions(clearButton, copyButton, downloadMarkdown, downloadCsv),
197
+ });
198
+
199
+ slot.append(shell.node);
200
+
201
+ return {};
202
+ }
203
+
204
+ export default { mount };
@@ -0,0 +1,195 @@
1
+ // A rotating duty roster. Names take turns across a date range, with a fixed
2
+ // number of slots per day, an optional weekend skip, and pinned dates.
3
+ import {
4
+ el, toast, download, bench, stack, panel, textArea, numberInput, button,
5
+ actions, toolcard, blank, notice, checkbox, selectInput,
6
+ } from "../core.js";
7
+
8
+ function parseNames(text) {
9
+ return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
10
+ }
11
+
12
+ function parsePins(text) {
13
+ const pins = new Map();
14
+ for (const line of text.split(/\r?\n/)) {
15
+ const match = /^(\d{4}-\d{2}-\d{2})\s*[:|,]\s*(.+)$/.exec(line.trim());
16
+ if (match) pins.set(match[1], match[2].split(/\s*(?:,|\|)\s*/).map((name) => name.trim()).filter(Boolean));
17
+ }
18
+ return pins;
19
+ }
20
+
21
+ function iso(date) {
22
+ // Local date parts, not toISOString: a browser east or west of UTC would
23
+ // otherwise show yesterday or tomorrow in the date field.
24
+ const year = date.getFullYear();
25
+ const month = String(date.getMonth() + 1).padStart(2, "0");
26
+ const day = String(date.getDate()).padStart(2, "0");
27
+ return `${year}-${month}-${day}`;
28
+ }
29
+
30
+ export function mount(slot) {
31
+ const names = textArea({ rows: 7, placeholder: "Ani\nBudi\nCitra\nDewi", "aria-label": "Names" });
32
+ const start = el("input", {
33
+ 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",
34
+ type: "date",
35
+ value: iso(new Date()),
36
+ "aria-label": "Start date",
37
+ });
38
+ const end = el("input", {
39
+ 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",
40
+ type: "date",
41
+ value: iso(new Date(Date.now() + 13 * 86400000)),
42
+ "aria-label": "End date",
43
+ });
44
+ const perDay = numberInput({ min: "1", max: "20", step: "1", value: "1", "aria-label": "People per day" });
45
+ const skipWeekend = checkbox("Skip weekends", { checked: true });
46
+ const pins = textArea({ rows: 3, placeholder: "2026-10-25: Ani", "aria-label": "Pinned dates" });
47
+ const order = selectInput(
48
+ [
49
+ { value: "forward", label: "Forward" },
50
+ { value: "reverse", label: "Reverse" },
51
+ ],
52
+ { value: "forward", "aria-label": "Rotation direction" }
53
+ );
54
+
55
+ const error = notice("warn");
56
+ const summary = el("p", { class: "text-sm text-ink-2" });
57
+ const table = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
58
+ const empty = blank("calendar-check", "Add names and a date range.");
59
+
60
+ let rows = [];
61
+
62
+ function build() {
63
+ const list = parseNames(names.value);
64
+ const pinned = parsePins(pins.value);
65
+ error.write("");
66
+ rows = [];
67
+ if (!list.length) return;
68
+
69
+ const from = new Date(`${start.value}T00:00:00`);
70
+ const to = new Date(`${end.value}T00:00:00`);
71
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
72
+ error.write("Pick a start and an end date.");
73
+ return;
74
+ }
75
+ if (to < from) {
76
+ error.write("The end date is before the start date.");
77
+ return;
78
+ }
79
+
80
+ const slots = Math.max(1, Math.min(Number(perDay.value) || 1, list.length));
81
+ const sequence = order.value === "reverse" ? [...list].reverse() : [...list];
82
+ let cursor = 0;
83
+ let guard = 0;
84
+
85
+ for (let day = new Date(from); day <= to && guard < 400; day.setDate(day.getDate() + 1), guard++) {
86
+ const weekday = day.getDay();
87
+ if (skipWeekend.input.checked && (weekday === 0 || weekday === 6)) continue;
88
+ const key = iso(day);
89
+ if (pinned.has(key)) {
90
+ rows.push({ date: key, people: pinned.get(key), pinned: true });
91
+ continue;
92
+ }
93
+ const people = [];
94
+ for (let i = 0; i < slots; i++) {
95
+ people.push(sequence[cursor % sequence.length]);
96
+ cursor++;
97
+ }
98
+ rows.push({ date: key, people, pinned: false });
99
+ }
100
+ if (guard >= 400) error.write("That range is very long, so it stops at 400 days.");
101
+ }
102
+
103
+ function render() {
104
+ empty.hidden = rows.length > 0;
105
+ table.hidden = rows.length === 0;
106
+ if (!rows.length) {
107
+ summary.textContent = "";
108
+ table.replaceChildren();
109
+ return;
110
+ }
111
+ const counts = new Map();
112
+ for (const row of rows) for (const name of row.people) counts.set(name, (counts.get(name) ?? 0) + 1);
113
+ const spread = [...counts.values()];
114
+ const even = spread.length ? `${Math.min(...spread)} to ${Math.max(...spread)} turns each` : "";
115
+ summary.textContent = `${rows.length} day${rows.length === 1 ? "" : "s"} · ${counts.size} people · ${even}`;
116
+
117
+ const head = el("div", {
118
+ class: "grid gap-3 border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase [grid-template-columns:minmax(110px,150px)_minmax(0,1fr)]",
119
+ }, el("span", {}, "Date"), el("span", {}, "On duty"));
120
+
121
+ const body = rows.map((row) =>
122
+ el("div", {
123
+ class: "grid gap-3 border-b border-orbit px-3 py-2 text-sm last:border-b-0 [grid-template-columns:minmax(110px,150px)_minmax(0,1fr)]",
124
+ },
125
+ el("span", { class: "font-mono text-2xs text-ink-3 tabular-nums" }, row.date),
126
+ el("span", { class: "wrap-anywhere" }, row.people.join(", "),
127
+ row.pinned ? el("span", { class: "ms-2 rounded-full bg-jade-soft px-1.5 py-0.5 text-2xs font-semibold text-jade" }, "pinned") : null))
128
+ );
129
+
130
+ table.replaceChildren(head, ...body);
131
+ }
132
+
133
+ function run() {
134
+ build();
135
+ render();
136
+ }
137
+
138
+ names.addEventListener("input", run);
139
+ start.addEventListener("change", run);
140
+ end.addEventListener("change", run);
141
+ perDay.addEventListener("input", run);
142
+ skipWeekend.input.addEventListener("change", run);
143
+ pins.addEventListener("input", run);
144
+ order.addEventListener("change", run);
145
+
146
+ const copyButton = button("Copy roster", {
147
+ icon: "copy",
148
+ onclick: async () => {
149
+ if (!rows.length) return;
150
+ const text = rows.map((row) => `${row.date}\t${row.people.join(", ")}`).join("\n");
151
+ try {
152
+ await navigator.clipboard.writeText(text);
153
+ toast("Roster copied");
154
+ } catch {
155
+ error.write("The clipboard is blocked here. Download the CSV instead.");
156
+ }
157
+ },
158
+ });
159
+
160
+ const downloadButton = button("Download CSV", {
161
+ icon: "download",
162
+ kind: "fill",
163
+ onclick: () => {
164
+ if (!rows.length) return;
165
+ const csv = [["date", "people"], ...rows.map((row) => [row.date, row.people.join("; ")])]
166
+ .map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
167
+ .join("\n");
168
+ download("roster.csv", csv, "text/csv");
169
+ },
170
+ });
171
+
172
+ run();
173
+
174
+ const shell = toolcard({
175
+ body: bench(
176
+ stack(
177
+ panel("Names", names, "One per line. The rotation continues in this order."),
178
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("From", start), panel("To", end)),
179
+ panel("People per day", perDay),
180
+ panel("Direction", order),
181
+ skipWeekend,
182
+ panel("Pinned dates", pins, "One per line, like 2026-10-25: Ani."),
183
+ error
184
+ ),
185
+ stack(summary, empty, table)
186
+ ),
187
+ foot: actions(copyButton, downloadButton),
188
+ });
189
+
190
+ slot.append(shell.node);
191
+
192
+ return {};
193
+ }
194
+
195
+ export default { mount };
@@ -0,0 +1,252 @@
1
+ // A CSV with the columns and the types you describe. The same seed always
2
+ // produces the same file, so a fixture can be regenerated when it drifts.
3
+ import {
4
+ el, icon, toast, download, bench, stack, panel, textInput, selectInput,
5
+ numberInput, button, actions, toolcard, blank, notice,
6
+ } from "../core.js";
7
+ import { toCsv } from "../csv.js";
8
+
9
+ const TYPES = [
10
+ { value: "text", label: "Text" },
11
+ { value: "word", label: "Word" },
12
+ { value: "number", label: "Number" },
13
+ { value: "decimal", label: "Decimal" },
14
+ { value: "boolean", label: "Boolean" },
15
+ { value: "date", label: "Date" },
16
+ { value: "email", label: "Email" },
17
+ { value: "uuid", label: "UUID" },
18
+ { value: "choice", label: "Choice list" },
19
+ ];
20
+
21
+ const WORDS = [
22
+ "alpha", "bravo", "cedar", "delta", "ember", "field", "grove", "harbor", "ivory", "juniper",
23
+ "kelp", "lumen", "meadow", "north", "orbit", "pine", "quartz", "ridge", "summit", "tide",
24
+ "umber", "vale", "willow", "xenon", "yarrow", "zephyr",
25
+ ];
26
+
27
+ /** Mulberry32: a small deterministic generator, so a seed is worth writing down. */
28
+ function seeded(seed) {
29
+ let state = seed >>> 0;
30
+ return () => {
31
+ state = (state + 0x6d2b79f5) >>> 0;
32
+ let t = state;
33
+ t = Math.imul(t ^ (t >>> 15), t | 1);
34
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
35
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
36
+ };
37
+ }
38
+
39
+ function pick(random, list) {
40
+ return list[Math.floor(random() * list.length)];
41
+ }
42
+
43
+ function uuid(random) {
44
+ const hex = "0123456789abcdef";
45
+ let out = "";
46
+ for (let i = 0; i < 32; i++) {
47
+ if (i === 8 || i === 12 || i === 16 || i === 20) out += "-";
48
+ out += i === 12 ? "4" : hex[Math.floor(random() * 16)];
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function value(type, choices, random, rowIndex) {
54
+ switch (type) {
55
+ case "word":
56
+ return pick(random, WORDS);
57
+ case "number":
58
+ return String(Math.floor(random() * 1000));
59
+ case "decimal":
60
+ return (random() * 100).toFixed(2);
61
+ case "boolean":
62
+ return random() > 0.5 ? "true" : "false";
63
+ case "date": {
64
+ const day = new Date(Date.UTC(2025, 0, 1) + Math.floor(random() * 700) * 86400000);
65
+ return day.toISOString().slice(0, 10);
66
+ }
67
+ case "email":
68
+ return `${pick(random, WORDS)}${rowIndex + 1}@example.com`;
69
+ case "uuid":
70
+ return uuid(random);
71
+ case "choice":
72
+ return choices.length ? pick(random, choices) : "";
73
+ default:
74
+ return `${pick(random, WORDS)} ${pick(random, WORDS)}`;
75
+ }
76
+ }
77
+
78
+ export function mount(slot) {
79
+ let columns = [
80
+ { name: "id", type: "number", choices: "" },
81
+ { name: "name", type: "word", choices: "" },
82
+ { name: "active", type: "boolean", choices: "" },
83
+ ];
84
+
85
+ const rows = numberInput({ min: "1", max: "10000", step: "1", value: "50", "aria-label": "Row count" });
86
+ const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
87
+ const error = notice("warn");
88
+ const summary = el("p", { class: "text-sm text-ink-2" });
89
+ const list = el("div", { class: "grid gap-2" });
90
+ const preview = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
91
+ const empty = blank("table", "Add a column to see the preview.");
92
+
93
+ const addButton = button("Add column", { icon: "plus", kind: "quiet" });
94
+ const generate = button("Generate", { icon: "refresh-cw", kind: "fill" });
95
+ const downloadButton = button("Download CSV", { icon: "download" });
96
+
97
+ let table = [];
98
+ let header = [];
99
+
100
+ function renderColumns() {
101
+ list.replaceChildren(
102
+ ...columns.map((column, index) =>
103
+ el(
104
+ "div",
105
+ { class: "grid gap-2 rounded-r1 border border-orbit bg-deck-2 p-2 [grid-template-columns:minmax(0,1fr)_auto]" },
106
+ el("div", { class: "grid gap-2 sm:grid-cols-2" },
107
+ textInput({
108
+ value: column.name,
109
+ placeholder: `column_${index + 1}`,
110
+ "aria-label": `Column ${index + 1} name`,
111
+ oninput: (event) => {
112
+ column.name = event.target.value;
113
+ run();
114
+ },
115
+ }),
116
+ selectInput(
117
+ TYPES.map((type) => ({ value: type.value, label: type.label })),
118
+ {
119
+ value: column.type,
120
+ "aria-label": `Column ${index + 1} type`,
121
+ onchange: (event) => {
122
+ column.type = event.target.value;
123
+ renderColumns();
124
+ run();
125
+ },
126
+ }
127
+ ),
128
+ column.type === "choice"
129
+ ? textInput({
130
+ value: column.choices,
131
+ placeholder: "red, green, blue",
132
+ "aria-label": `Column ${index + 1} choices`,
133
+ class: "sm:col-span-2",
134
+ oninput: (event) => {
135
+ column.choices = event.target.value;
136
+ run();
137
+ },
138
+ })
139
+ : null),
140
+ el("button", {
141
+ class: "inline-flex size-9 cursor-pointer items-center justify-center self-start rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger disabled:opacity-40",
142
+ type: "button",
143
+ "aria-label": `Remove column ${index + 1}`,
144
+ disabled: columns.length === 1,
145
+ onclick: () => {
146
+ columns.splice(index, 1);
147
+ renderColumns();
148
+ run();
149
+ },
150
+ }, icon("trash", "size-4"))
151
+ )
152
+ )
153
+ );
154
+ }
155
+
156
+ function hashSeed(text) {
157
+ let hash = 2166136261;
158
+ for (let i = 0; i < text.length; i++) {
159
+ hash ^= text.charCodeAt(i);
160
+ hash = Math.imul(hash, 16777619);
161
+ }
162
+ return hash >>> 0;
163
+ }
164
+
165
+ function renderPreview() {
166
+ empty.hidden = table.length > 0;
167
+ preview.hidden = table.length === 0;
168
+ if (!table.length) {
169
+ preview.replaceChildren();
170
+ return;
171
+ }
172
+ const shown = table.slice(0, 8);
173
+ const head = el("div", {
174
+ class: "grid border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase",
175
+ style: `grid-template-columns: repeat(${header.length}, minmax(110px, 1fr))`,
176
+ }, ...header.map((cell) => el("span", { class: "truncate" }, cell)));
177
+
178
+ const body = shown.map((row) =>
179
+ el("div", {
180
+ class: "grid border-b border-orbit px-3 py-1.5 text-sm last:border-b-0",
181
+ style: `grid-template-columns: repeat(${header.length}, minmax(110px, 1fr))`,
182
+ }, ...row.map((cell) => el("span", { class: "truncate font-mono text-2xs text-ink-2" }, cell)))
183
+ );
184
+
185
+ preview.replaceChildren(head, ...body);
186
+ }
187
+
188
+ function run() {
189
+ error.write("");
190
+ const count = Math.max(1, Math.min(10000, Number(rows.value) || 1));
191
+ const names = columns.map((column, index) => column.name.trim() || `column_${index + 1}`);
192
+ if (new Set(names).size !== names.length) error.write("Two columns share a name, so the CSV will have duplicates.");
193
+
194
+ const random = seeded(hashSeed(seed.value || "mevebu"));
195
+ header = names;
196
+ table = Array.from({ length: count }, (_, rowIndex) =>
197
+ columns.map((column) =>
198
+ value(
199
+ column.type,
200
+ column.choices.split(",").map((choice) => choice.trim()).filter(Boolean),
201
+ random,
202
+ rowIndex
203
+ )
204
+ )
205
+ );
206
+
207
+ summary.textContent = `${count} row${count === 1 ? "" : "s"} · ${columns.length} column${columns.length === 1 ? "" : "s"} · seed ${seed.value || "mevebu"}`;
208
+ renderPreview();
209
+ }
210
+
211
+ addButton.addEventListener("click", () => {
212
+ columns.push({ name: `column_${columns.length + 1}`, type: "text", choices: "" });
213
+ renderColumns();
214
+ run();
215
+ });
216
+ rows.addEventListener("input", run);
217
+ seed.addEventListener("input", run);
218
+ generate.addEventListener("click", () => {
219
+ run();
220
+ toast("Sample rebuilt");
221
+ });
222
+ downloadButton.addEventListener("click", () => {
223
+ if (!table.length) return;
224
+ download("sample.csv", toCsv([header, ...table]), "text/csv");
225
+ });
226
+
227
+ renderColumns();
228
+ run();
229
+
230
+ const shell = toolcard({
231
+ body: bench(
232
+ stack(
233
+ panel("Rows", rows),
234
+ panel("Seed", seed, "The same seed always makes the same file."),
235
+ el("div", { class: "grid gap-1.5" },
236
+ el("div", { class: "flex items-center justify-between gap-2" },
237
+ el("span", { class: "text-sm font-semibold text-ink-2" }, "Columns"),
238
+ addButton),
239
+ list),
240
+ error
241
+ ),
242
+ stack(summary, empty, preview)
243
+ ),
244
+ foot: actions(generate, downloadButton),
245
+ });
246
+
247
+ slot.append(shell.node);
248
+
249
+ return {};
250
+ }
251
+
252
+ export default { mount };