mevebu 2.0.0 → 2.2.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,224 @@
1
+ // The JSON an endpoint returns, written once and reused as a fixture. Fields are
2
+ // defined in a small table, and the same definition can be shown as a list with
3
+ // pagination, a single item, or an error.
4
+ import {
5
+ el, icon, toast, download, bench, stack, panel, textInput, selectInput,
6
+ numberInput, button, actions, toolcard, blank, notice, output, chipRow,
7
+ } from "../core.js";
8
+ import { seeded, pick, uuid } from "../random.js";
9
+
10
+ const TYPES = [
11
+ { value: "text", label: "Text" },
12
+ { value: "word", label: "Word" },
13
+ { value: "number", label: "Number" },
14
+ { value: "decimal", label: "Decimal" },
15
+ { value: "boolean", label: "Boolean" },
16
+ { value: "date", label: "Date" },
17
+ { value: "email", label: "Email" },
18
+ { value: "uuid", label: "UUID" },
19
+ ];
20
+
21
+ const WORDS = ["north", "cedar", "orbit", "summit", "tide", "willow", "ember", "quartz"];
22
+
23
+ function value(type, random, index) {
24
+ switch (type) {
25
+ case "word": return pick(random, WORDS);
26
+ case "number": return Math.floor(random() * 5000);
27
+ case "decimal": return Number((random() * 200).toFixed(2));
28
+ case "boolean": return random() > 0.5;
29
+ case "date": return new Date(Date.UTC(2025, 0, 1) + Math.floor(random() * 6e10)).toISOString();
30
+ case "email": return `${pick(random, WORDS)}${index + 1}@example.com`;
31
+ case "uuid": return uuid(random);
32
+ default: return `${pick(random, WORDS)} ${pick(random, WORDS)}`;
33
+ }
34
+ }
35
+
36
+ export function mount(slot) {
37
+ let fields = [
38
+ { name: "id", type: "uuid" },
39
+ { name: "title", type: "text" },
40
+ { name: "price", type: "decimal" },
41
+ { name: "in_stock", type: "boolean" },
42
+ { name: "created_at", type: "date" },
43
+ ];
44
+ let shape = "list";
45
+
46
+ const resource = textInput({ value: "items", "aria-label": "Resource name" });
47
+ const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
48
+ const items = numberInput({ min: "1", max: "100", step: "1", value: "5", "aria-label": "Items" });
49
+ const status = selectInput(
50
+ [
51
+ { value: "200", label: "200 OK" },
52
+ { value: "201", label: "201 Created" },
53
+ { value: "400", label: "400 Bad Request" },
54
+ { value: "401", label: "401 Unauthorized" },
55
+ { value: "404", label: "404 Not Found" },
56
+ { value: "422", label: "422 Unprocessable" },
57
+ { value: "500", label: "500 Server Error" },
58
+ ],
59
+ { value: "200", "aria-label": "HTTP status" }
60
+ );
61
+
62
+ const shapeRow = chipRow(
63
+ [
64
+ { value: "list", label: "List" },
65
+ { value: "item", label: "Single item" },
66
+ { value: "error", label: "Error" },
67
+ ],
68
+ { value: "list", label: "Payload shape", onChange: (next) => { shape = next; sync(); run(); } }
69
+ );
70
+
71
+ const error = notice("warn");
72
+ const summary = el("p", { class: "text-sm text-ink-2" });
73
+ const result = output({ rows: 16 });
74
+ const empty = blank("braces", "Define a field to build the payload.");
75
+ const list = el("div", { class: "grid gap-2" });
76
+
77
+ const itemsPanel = panel("Items", items);
78
+ const statusPanel = panel("HTTP status", status);
79
+
80
+ function sync() {
81
+ itemsPanel.hidden = shape !== "list";
82
+ statusPanel.hidden = shape !== "error";
83
+ }
84
+
85
+ function renderFields() {
86
+ list.replaceChildren(
87
+ ...fields.map((field, index) =>
88
+ el("div", { class: "grid gap-2 rounded-r1 border border-orbit bg-deck-2 p-2 [grid-template-columns:minmax(0,1fr)_auto]" },
89
+ el("div", { class: "grid gap-2 sm:grid-cols-2" },
90
+ textInput({
91
+ value: field.name,
92
+ placeholder: `field_${index + 1}`,
93
+ "aria-label": `Field ${index + 1} name`,
94
+ oninput: (event) => { field.name = event.target.value; run(); },
95
+ }),
96
+ selectInput(TYPES.map((type) => ({ value: type.value, label: type.label })), {
97
+ value: field.type,
98
+ "aria-label": `Field ${index + 1} type`,
99
+ onchange: (event) => { field.type = event.target.value; run(); },
100
+ })),
101
+ el("button", {
102
+ 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",
103
+ type: "button",
104
+ "aria-label": `Remove field ${index + 1}`,
105
+ disabled: fields.length === 1,
106
+ onclick: () => { fields.splice(index, 1); renderFields(); run(); },
107
+ }, icon("trash", "size-4")))
108
+ )
109
+ );
110
+ }
111
+
112
+ function one(random, index) {
113
+ const out = {};
114
+ for (const field of fields) {
115
+ const key = field.name.trim() || `field_${index + 1}`;
116
+ out[key] = value(field.type, random, index);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function run() {
122
+ error.write("");
123
+ const random = seeded(seed.value || "mevebu");
124
+ const name = resource.value.trim() || "items";
125
+ const count = Math.max(1, Math.min(100, Number(items.value) || 1));
126
+ const rows = Array.from({ length: count }, (_, index) => one(random, index));
127
+
128
+ let payload;
129
+ let http = Number(status.value);
130
+ if (shape === "list") {
131
+ payload = {
132
+ data: rows,
133
+ meta: { page: 1, per_page: count, total: count * 4, has_more: true },
134
+ };
135
+ http = 200;
136
+ } else if (shape === "item") {
137
+ payload = { data: rows[0] };
138
+ http = 200;
139
+ } else {
140
+ const messages = {
141
+ 400: "The request body is not valid JSON.",
142
+ 401: "Missing or expired token.",
143
+ 404: `No ${name} with that id.`,
144
+ 422: "The title field is required.",
145
+ 500: "Something went wrong on our side.",
146
+ };
147
+ payload = {
148
+ error: { status: http, code: http === 401 ? "unauthorized" : http === 404 ? "not_found" : "bad_request", message: messages[http] ?? "Request failed." },
149
+ };
150
+ }
151
+
152
+ result.write(JSON.stringify(payload, null, 2));
153
+ empty.hidden = true;
154
+ result.hidden = false;
155
+ const bytes = new TextEncoder().encode(JSON.stringify(payload)).length;
156
+ summary.textContent = `${http} · ${shape} · ${bytes} bytes${shape === "list" ? ` · ${count} item${count === 1 ? "" : "s"}` : ""}`;
157
+ }
158
+
159
+ resource.addEventListener("input", run);
160
+ seed.addEventListener("input", run);
161
+ items.addEventListener("input", run);
162
+ status.addEventListener("change", run);
163
+
164
+ const addButton = button("Add field", {
165
+ icon: "plus",
166
+ kind: "quiet",
167
+ onclick: () => {
168
+ fields.push({ name: `field_${fields.length + 1}`, type: "text" });
169
+ renderFields();
170
+ run();
171
+ },
172
+ });
173
+
174
+ const copyButton = button("Copy JSON", {
175
+ icon: "copy",
176
+ onclick: async () => {
177
+ try {
178
+ await navigator.clipboard.writeText(result.textContent);
179
+ toast("Payload copied");
180
+ } catch {
181
+ error.write("The clipboard is blocked here. Download the file instead.");
182
+ }
183
+ },
184
+ });
185
+
186
+ const downloadButton = button("Download .json", {
187
+ icon: "download",
188
+ kind: "fill",
189
+ onclick: () => {
190
+ if (!result.textContent) return;
191
+ download(`${(resource.value.trim() || "payload").replace(/[^\w-]+/g, "-")}-${shape}.json`, result.textContent, "application/json");
192
+ },
193
+ });
194
+
195
+ renderFields();
196
+ sync();
197
+ run();
198
+
199
+ const shell = toolcard({
200
+ body: bench(
201
+ stack(
202
+ panel("Resource", resource, "Used in the error message and the file name."),
203
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Shape"), shapeRow),
204
+ itemsPanel,
205
+ statusPanel,
206
+ panel("Seed", seed),
207
+ el("div", { class: "grid gap-1.5" },
208
+ el("div", { class: "flex items-center justify-between gap-2" },
209
+ el("span", { class: "text-sm font-semibold text-ink-2" }, "Fields"),
210
+ addButton),
211
+ list),
212
+ error
213
+ ),
214
+ stack(summary, empty, result)
215
+ ),
216
+ foot: actions(copyButton, downloadButton),
217
+ });
218
+
219
+ slot.append(shell.node);
220
+
221
+ return {};
222
+ }
223
+
224
+ export default { mount };
@@ -0,0 +1,149 @@
1
+ // A passphrase from a bundled word list. The entropy reading is arithmetic on
2
+ // the size of that list, not an estimate: log2(length) bits per word.
3
+ import {
4
+ el, icon, toast, bench, stack, panel, numberInput, selectInput, button,
5
+ actions, toolcard, notice, checkbox, rangeInput, copyButton, chipRow,
6
+ } from "../core.js";
7
+
8
+ const WORDS = `acorn amber anchor apple arrow aspen attic autumn badge bagel baker bamboo
9
+ banjo barrel basil basket beach beacon bean bear beaver bell belt bench berry birch biscuit
10
+ bison blade blanket bloom board boat bolt bonfire book boot bottle boulder bowl box braid branch
11
+ brass bread brick bridge broom brush bubble bucket buffalo bugle bulb bunny burrow bus butter
12
+ button cabin cactus camel candle candy canoe canvas canyon carbon cargo carpet carrot cart castle
13
+ cedar chalk cherry chess chest chicken chisel cider cinema circle city clamp clay cliff cloak clock
14
+ cloud clover coach coast cobra cocoa comet compass copper coral cork corn cotton couch cougar
15
+ country court cove crab crane crater crayon creek crest cricket crimson crow crown crystal cube
16
+ cupboard curl curtain cycle daisy dance dawn deer delta denim desert desk diamond dice diner dock
17
+ dolphin dome donkey door dove dragon drift drum dune dusk eagle earth easel echo egg elbow elder
18
+ elk ember engine envelope falcon fang farm feather fence fern ferry field fig finch fire fish flag
19
+ flame flask fleet flint float flock flute foam fog forest fork fossil fountain fox frame frost fruit
20
+ galley garden garlic gate gecko gem ginger glacier glass glove goat gold goose gourd grain grape
21
+ grass gravel green grove guitar gull gust hammer harbor harp harvest hawk hay hedge helmet heron
22
+ hill hinge hive holly honey hoof hook horn horse hotel hut iceberg ink inlet iron island ivory ivy
23
+ jacket jade jaguar jar jasmine jelly jewel jigsaw jungle juniper kayak kettle key kite kitten kiwi
24
+ knife knot koala ladder lagoon lake lamp lantern lapel laser latch lattice leaf ledge lemon lens
25
+ letter lever lily lime linen lion lizard lobster lock log lotus lumber lunar lynx magnet magneto
26
+ maize mango maple marble march marsh mask mast meadow medal melon mesa metal meteor mint mirror
27
+ mist mitten mole monkey moon moose mortar moss moth motor mound mountain mouse mushroom music
28
+ napkin needle nest nettle nickel night noodle north note nugget oak oasis ocean olive onion opal
29
+ orange orbit orchid otter oven owl oxide oyster paddle page palm panda panel paper parcel parrot
30
+ pasture patio peach peanut pear pebble pelican pen pencil pepper perch petal piano picnic pier
31
+ pigeon pillar pine piston pitcher plain plane planet plank plant plaster plate plaza plum pocket
32
+ pond pony poppy porch portal potato prairie prism pumpkin puzzle quail quarry quartz quill quiver
33
+ rabbit radar raft rail rain rake ram ranch raven ravine ray razor reef relay ribbon rice ridge
34
+ rifle ring river road robin rocket rod roof root rope rose rowboat ruby rug ruler saddle sage sail
35
+ salmon sand sapphire satchel saw scarf school scooter scout sea seal seed shade shark sheep shelf
36
+ shell shield ship shoe shore shovel shrub silk silver siren skate sketch sky sled slope snail snake
37
+ snow soap sock sofa solar song soup sparrow spice spider spike spine sponge spoon spring spruce
38
+ square squid stable stack star statue steam steel stem step stick stone stool storm stove strap
39
+ stream street string sugar summer sun swan sweat sweet swift swing table tackle tail talon tank
40
+ tapir tea teal temple tent thistle thorn thread thunder tide tiger timber tin toad toast tomato
41
+ tone tool tooth topaz torch tower track trail train trap tray tree trek trench tribe trout truck
42
+ trumpet trunk tulip tunnel turtle twig valley vanilla vase velvet vine violet violin vista wagon
43
+ walnut walrus wand wave wax wheat wheel whip whisper willow wind window wing winter wire wolf wood
44
+ wool wrench yacht yarn yellow yew zebra zinc zipper zone`.split(/\s+/).filter(Boolean);
45
+
46
+ const SEPARATORS = [
47
+ { value: "-", label: "hyphen" },
48
+ { value: " ", label: "space" },
49
+ { value: ".", label: "dot" },
50
+ { value: "_", label: "underscore" },
51
+ ];
52
+
53
+ export function mount(slot) {
54
+ let phrase = "";
55
+
56
+ const count = rangeInput({ min: "2", max: "12", step: "1", value: "5", "aria-label": "Words" });
57
+ const countLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "5 words");
58
+ const separator = selectInput(SEPARATORS, { value: "-", "aria-label": "Separator" });
59
+ const capitalize = checkbox("Capitalize each word", { checked: false });
60
+ const number = checkbox("Add a number", { checked: false });
61
+ const symbol = checkbox("Add a symbol", { checked: false });
62
+
63
+ const error = notice("warn");
64
+ const output = el("p", {
65
+ class: "m-0 wrap-anywhere font-mono text-[clamp(20px,3.4vw,28px)] font-bold leading-[1.4] tracking-[-0.01em] text-ink",
66
+ "aria-live": "polite",
67
+ }, "");
68
+ const entropy = el("p", { class: "m-0 text-sm text-ink-2" });
69
+ const panelBody = el("div", { class: "grid gap-2 rounded-r2 border border-orbit bg-deck-2 p-4" }, output, entropy);
70
+
71
+ function make() {
72
+ error.write("");
73
+ if (!WORDS.length) {
74
+ error.write("The word list is empty.");
75
+ return;
76
+ }
77
+ const size = Math.max(2, Math.min(12, Number(count.value) || 5));
78
+ const random = crypto.getRandomValues(new Uint32Array(size + 2));
79
+ const parts = [];
80
+ for (let i = 0; i < size; i++) {
81
+ let word = WORDS[random[i] % WORDS.length];
82
+ if (capitalize.input.checked) word = word[0].toUpperCase() + word.slice(1);
83
+ parts.push(word);
84
+ }
85
+ if (number.input.checked) parts.push(String(random[size] % 100));
86
+ if (symbol.input.checked) parts.push("!@#$%&*?"[random[size + 1] % 9]);
87
+
88
+ const join = separator.value === " " ? " " : separator.value;
89
+ phrase = parts.join(join);
90
+
91
+ // Each word is log2(WORDS.length) bits, because every index is equally
92
+ // likely. The optional number and symbol add about 6.6 and 3.2 bits.
93
+ const bits = size * Math.log2(WORDS.length) + (number.input.checked ? Math.log2(100) : 0) + (symbol.input.checked ? Math.log2(9) : 0);
94
+ // Half the space on average, at 100 billion guesses a second.
95
+ const seconds = 2 ** (bits - 1) / 1e11;
96
+ entropy.textContent = `${Math.round(bits)} bits · ${formatTime(seconds)} to guess at 100 billion tries a second`;
97
+ output.textContent = phrase;
98
+ }
99
+
100
+ function formatTime(seconds) {
101
+ if (seconds < 1) return "under a second";
102
+ if (seconds < 60) return `about ${Math.round(seconds)} seconds`;
103
+ const years = seconds / 31_557_600;
104
+ if (seconds < 3600) return `about ${Math.max(1, Math.round(seconds / 60))} minutes`;
105
+ if (seconds < 86400) return `about ${Math.round(seconds / 3600)} hours`;
106
+ if (years < 1) return `about ${Math.round(seconds / 86400)} days`;
107
+ if (years < 1000) return `about ${Math.round(years)} years`;
108
+ if (years < 1e6) return `about ${Math.round(years / 1000)} thousand years`;
109
+ if (years < 1e9) return `about ${Math.round(years / 1e6)} million years`;
110
+ return "longer than the universe has existed";
111
+ }
112
+
113
+ count.addEventListener("input", () => {
114
+ countLabel.textContent = `${count.value} word${count.value === "1" ? "" : "s"}`;
115
+ make();
116
+ });
117
+ separator.addEventListener("change", make);
118
+ capitalize.input.addEventListener("change", make);
119
+ number.input.addEventListener("change", make);
120
+ symbol.input.addEventListener("change", make);
121
+
122
+ const regenerate = button("Generate", { icon: "refresh-cw", kind: "fill", onclick: make });
123
+ const copy = copyButton(() => phrase, "Copy passphrase");
124
+
125
+ make();
126
+
127
+ const shell = toolcard({
128
+ body: bench(
129
+ stack(
130
+ el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Words"), count, countLabel),
131
+ panel("Separator", separator),
132
+ capitalize,
133
+ number,
134
+ symbol,
135
+ error
136
+ ),
137
+ stack(panelBody,
138
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
139
+ "The word list is bundled with the app, so this works offline. Each word is worth about 9 bits; five words is about 46 bits, which suits a login with rate limiting. Use more words for anything that protects money."))
140
+ ),
141
+ foot: actions(regenerate, copy),
142
+ });
143
+
144
+ slot.append(shell.node);
145
+
146
+ return {};
147
+ }
148
+
149
+ export default { mount };
@@ -5,6 +5,7 @@ import {
5
5
  numberInput, button, actions, toolcard, blank, notice,
6
6
  } from "../core.js";
7
7
  import { toCsv } from "../csv.js";
8
+ import { seeded, pick, uuid } from "../random.js";
8
9
 
9
10
  const TYPES = [
10
11
  { value: "text", label: "Text" },
@@ -24,32 +25,6 @@ const WORDS = [
24
25
  "umber", "vale", "willow", "xenon", "yarrow", "zephyr",
25
26
  ];
26
27
 
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
28
  function value(type, choices, random, rowIndex) {
54
29
  switch (type) {
55
30
  case "word":
@@ -153,15 +128,6 @@ export function mount(slot) {
153
128
  );
154
129
  }
155
130
 
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
131
  function renderPreview() {
166
132
  empty.hidden = table.length > 0;
167
133
  preview.hidden = table.length === 0;
@@ -191,7 +157,7 @@ export function mount(slot) {
191
157
  const names = columns.map((column, index) => column.name.trim() || `column_${index + 1}`);
192
158
  if (new Set(names).size !== names.length) error.write("Two columns share a name, so the CSV will have duplicates.");
193
159
 
194
- const random = seeded(hashSeed(seed.value || "mevebu"));
160
+ const random = seeded(seed.value || "mevebu");
195
161
  header = names;
196
162
  table = Array.from({ length: count }, (_, rowIndex) =>
197
163
  columns.map((column) =>
@@ -0,0 +1,225 @@
1
+ // Fake survey responses. Each question carries weights, so a dashboard can be
2
+ // tested against answers that lean the way real answers lean, not uniform noise.
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
+ import { seeded, pick, uuid } from "../random.js";
9
+
10
+ const TYPES = [
11
+ { value: "scale", label: "Scale 1 to 5" },
12
+ { value: "single", label: "Single choice" },
13
+ { value: "multi", label: "Multiple choice" },
14
+ { value: "text", label: "Short text" },
15
+ ];
16
+
17
+ const WORDS = ["clear", "useful", "slow", "confusing", "helpful", "missing", "fast", "solid"];
18
+
19
+ function weighted(random, options) {
20
+ const total = options.reduce((sum, option) => sum + Math.max(0, option.weight), 0);
21
+ if (total <= 0) return pick(random, options).label;
22
+ let roll = random() * total;
23
+ for (const option of options) {
24
+ roll -= Math.max(0, option.weight);
25
+ if (roll <= 0) return option.label;
26
+ }
27
+ return options[options.length - 1].label;
28
+ }
29
+
30
+ export function mount(slot) {
31
+ let questions = [
32
+ {
33
+ text: "How easy was it to use?",
34
+ type: "scale",
35
+ options: [
36
+ { label: "1", weight: 1 },
37
+ { label: "2", weight: 2 },
38
+ { label: "3", weight: 5 },
39
+ { label: "4", weight: 7 },
40
+ { label: "5", weight: 5 },
41
+ ],
42
+ },
43
+ {
44
+ text: "Which part did you use most?",
45
+ type: "single",
46
+ options: [
47
+ { label: "Convert", weight: 6 },
48
+ { label: "Text", weight: 3 },
49
+ { label: "Data", weight: 2 },
50
+ ],
51
+ },
52
+ ];
53
+
54
+ const respondents = numberInput({ min: "1", max: "5000", step: "1", value: "50", "aria-label": "Respondents" });
55
+ const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
56
+ const withIds = { input: null };
57
+
58
+ const error = notice("warn");
59
+ const summary = el("p", { class: "text-sm text-ink-2" });
60
+ const list = el("div", { class: "grid gap-2" });
61
+ const preview = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
62
+ const empty = blank("clipboard-list", "Add a question to see responses.");
63
+
64
+ let table = [];
65
+ let header = [];
66
+
67
+ function renderQuestions() {
68
+ list.replaceChildren(
69
+ ...questions.map((question, index) => {
70
+ const optionRows = question.options.map((option, optionIndex) =>
71
+ el("div", { class: "flex items-center gap-2" },
72
+ textInput({
73
+ value: option.label,
74
+ placeholder: `Option ${optionIndex + 1}`,
75
+ "aria-label": `Question ${index + 1} option ${optionIndex + 1}`,
76
+ oninput: (event) => { option.label = event.target.value; run(); },
77
+ }),
78
+ numberInput({
79
+ value: String(option.weight),
80
+ min: "0",
81
+ max: "100",
82
+ "aria-label": `Question ${index + 1} option ${optionIndex + 1} weight`,
83
+ oninput: (event) => { option.weight = Number(event.target.value) || 0; run(); },
84
+ }),
85
+ el("button", {
86
+ class: "inline-flex size-8 flex-none cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger disabled:opacity-40",
87
+ type: "button",
88
+ "aria-label": `Remove option ${optionIndex + 1}`,
89
+ disabled: question.options.length <= 1,
90
+ onclick: () => { question.options.splice(optionIndex, 1); renderQuestions(); run(); },
91
+ }, icon("x", "size-3.5"))));
92
+
93
+ return el(
94
+ "section",
95
+ { class: "grid gap-2 rounded-r1 border border-orbit bg-deck-2 p-2.5" },
96
+ el("div", { class: "grid gap-2 [grid-template-columns:minmax(0,1fr)_auto]" },
97
+ textInput({
98
+ value: question.text,
99
+ placeholder: `Question ${index + 1}`,
100
+ "aria-label": `Question ${index + 1}`,
101
+ oninput: (event) => { question.text = event.target.value; run(); },
102
+ }),
103
+ el("button", {
104
+ class: "inline-flex size-9 cursor-pointer items-center justify-center rounded-[5px] border-0 bg-transparent text-ink-3 hover:bg-deck hover:text-danger disabled:opacity-40",
105
+ type: "button",
106
+ "aria-label": `Remove question ${index + 1}`,
107
+ disabled: questions.length === 1,
108
+ onclick: () => { questions.splice(index, 1); renderQuestions(); run(); },
109
+ }, icon("trash", "size-4"))),
110
+ selectInput(TYPES.map((type) => ({ value: type.value, label: type.label })), {
111
+ value: question.type,
112
+ "aria-label": `Question ${index + 1} type`,
113
+ onchange: (event) => { question.type = event.target.value; renderQuestions(); run(); },
114
+ }),
115
+ question.type === "text" ? null : el("div", { class: "grid gap-1.5" },
116
+ el("span", { class: "text-2xs font-semibold tracking-[0.06em] text-ink-3 uppercase" }, "Option and weight"),
117
+ ...optionRows)
118
+ );
119
+ })
120
+ );
121
+ }
122
+
123
+ function answer(question, random) {
124
+ if (question.type === "text") {
125
+ return `${pick(random, WORDS)} ${pick(random, WORDS)}`;
126
+ }
127
+ if (question.type === "multi") {
128
+ const chosen = question.options.filter(() => random() < Math.min(0.9, question.options.length ? 0.4 : 0));
129
+ return (chosen.length ? chosen : [pick(random, question.options)]).map((option) => option.label).join(" | ");
130
+ }
131
+ return weighted(random, question.options);
132
+ }
133
+
134
+ function run() {
135
+ error.write("");
136
+ const count = Math.max(1, Math.min(5000, Number(respondents.value) || 1));
137
+ const random = seeded(seed.value || "mevebu");
138
+ header = ["respondent_id", "submitted_at", ...questions.map((question) => question.text.trim() || "question")];
139
+ table = Array.from({ length: count }, () => {
140
+ const submitted = new Date(Date.UTC(2026, 0, 1) + Math.floor(random() * 90) * 86400000);
141
+ return [
142
+ uuid(random).slice(0, 8),
143
+ submitted.toISOString().slice(0, 10),
144
+ ...questions.map((question) => answer(question, random)),
145
+ ];
146
+ });
147
+ summary.textContent = `${count} respondent${count === 1 ? "" : "s"} · ${questions.length} question${questions.length === 1 ? "" : "s"} · seed ${seed.value || "mevebu"}`;
148
+ renderPreview();
149
+ }
150
+
151
+ function renderPreview() {
152
+ empty.hidden = table.length > 0;
153
+ preview.hidden = table.length === 0;
154
+ if (!table.length) {
155
+ preview.replaceChildren();
156
+ return;
157
+ }
158
+ const shown = table.slice(0, 8);
159
+ const columns = header.length;
160
+ const head = el("div", {
161
+ class: "grid border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase",
162
+ style: `grid-template-columns: repeat(${columns}, minmax(100px, 1fr))`,
163
+ }, ...header.map((cell) => el("span", { class: "truncate" }, cell)));
164
+ const body = shown.map((row) =>
165
+ el("div", {
166
+ class: "grid border-b border-orbit px-3 py-1.5 text-sm last:border-b-0",
167
+ style: `grid-template-columns: repeat(${columns}, minmax(100px, 1fr))`,
168
+ }, ...row.map((cell) => el("span", { class: "truncate font-mono text-2xs text-ink-2" }, cell)))
169
+ );
170
+ preview.replaceChildren(head, ...body);
171
+ }
172
+
173
+ respondents.addEventListener("input", run);
174
+ seed.addEventListener("input", run);
175
+
176
+ const addQuestion = button("Add question", {
177
+ icon: "plus",
178
+ kind: "quiet",
179
+ onclick: () => {
180
+ questions.push({ text: `Question ${questions.length + 1}`, type: "scale", options: [
181
+ { label: "1", weight: 1 }, { label: "2", weight: 2 }, { label: "3", weight: 5 }, { label: "4", weight: 5 }, { label: "5", weight: 2 },
182
+ ] });
183
+ renderQuestions();
184
+ run();
185
+ },
186
+ });
187
+
188
+ const downloadButton = button("Download CSV", {
189
+ icon: "download",
190
+ kind: "fill",
191
+ onclick: () => {
192
+ if (!table.length) return;
193
+ download("responses.csv", toCsv([header, ...table]), "text/csv");
194
+ toast("Responses ready");
195
+ },
196
+ });
197
+
198
+ renderQuestions();
199
+ run();
200
+
201
+ const shell = toolcard({
202
+ body: bench(
203
+ stack(
204
+ panel("Respondents", respondents),
205
+ panel("Seed", seed),
206
+ el("div", { class: "grid gap-1.5" },
207
+ el("div", { class: "flex items-center justify-between gap-2" },
208
+ el("span", { class: "text-sm font-semibold text-ink-2" }, "Questions"),
209
+ addQuestion),
210
+ list),
211
+ error
212
+ ),
213
+ stack(summary, empty, preview,
214
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
215
+ "Weights are relative, not percentages. A weight of 7 against 3 means the first answer is about twice as likely."))
216
+ ),
217
+ foot: actions(downloadButton),
218
+ });
219
+
220
+ slot.append(shell.node);
221
+
222
+ return {};
223
+ }
224
+
225
+ export default { mount };