feat: add settings menus to all 4 decide/choice canvas elements

Each choice component now has a gear (⚙) settings panel for configuring
title, options, criteria, and tool-specific parameters inline on the canvas.

- folk-choice-vote: title, options (color+label), QV budget, reset votes
- folk-choice-conviction: title, options (color+label), reset stakes
- folk-choice-rank: title, options (label), reset rankings
- folk-choice-spider: title, options, criteria with weight sliders, reset scores

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 15:47:26 -08:00
parent a5d4c22177
commit 7db591da17
4 changed files with 454 additions and 0 deletions

View File

@ -277,6 +277,23 @@ const styles = css`
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; } .participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drawer-toggle.active { background: rgba(255,255,255,0.3); } .drawer-toggle.active { background: rgba(255,255,255,0.3); }
.settings-toggle.active { background: rgba(255,255,255,0.3); }
.settings-panel { display: none; flex-direction: column; gap: 12px; padding: 12px; overflow-y: auto; height: calc(100% - 36px); }
.settings-open .settings-panel { display: flex; }
.settings-open .body { display: none !important; }
.settings-open .results-drawer { display: none !important; }
.settings-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; }
.settings-input { width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; }
.settings-input:focus { border-color: #d97706; }
.settings-item { display: flex; align-items: center; gap: 6px; padding: 4px 0; }
.settings-item input[type="text"] { flex: 1; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; min-width: 0; }
.settings-item input[type="color"] { width: 24px; height: 24px; border: none; border-radius: 4px; padding: 0; cursor: pointer; background: none; }
.settings-item .remove-btn { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; }
.settings-item .remove-btn:hover { color: #ef4444; background: #fef2f2; }
.settings-danger { background: none; border: 1px solid #fca5a5; color: #ef4444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 4px; }
.settings-danger:hover { background: #fef2f2; }
.settings-done { background: #d97706; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-size: 12px; font-weight: 500; width: 100%; margin-top: auto; }
.settings-done:hover { background: #b45309; }
`; `;
// -- Data types -- // -- Data types --
@ -349,6 +366,7 @@ export class FolkChoiceConviction extends FolkShape {
#userId = ""; #userId = "";
#userName = ""; #userName = "";
#drawerOpen = false; #drawerOpen = false;
#settingsOpen = false;
#tickInterval: ReturnType<typeof setInterval> | null = null; #tickInterval: ReturnType<typeof setInterval> | null = null;
// DOM refs // DOM refs
@ -359,6 +377,7 @@ export class FolkChoiceConviction extends FolkShape {
#votersEl: HTMLElement | null = null; #votersEl: HTMLElement | null = null;
#drawerEl: HTMLElement | null = null; #drawerEl: HTMLElement | null = null;
#chartEl: HTMLElement | null = null; #chartEl: HTMLElement | null = null;
#settingsEl: HTMLElement | null = null;
get title() { return this.#title; } get title() { return this.#title; }
set title(v: string) { this.#title = v; this.requestUpdate("title"); } set title(v: string) { this.#title = v; this.requestUpdate("title"); }
@ -442,6 +461,7 @@ export class FolkChoiceConviction extends FolkShape {
<span class="title-text">Conviction</span> <span class="title-text">Conviction</span>
</span> </span>
<div class="header-actions"> <div class="header-actions">
<button class="settings-toggle" title="Settings">&#x2699;</button>
<button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button> <button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button>
<button class="close-btn" title="Close">&times;</button> <button class="close-btn" title="Close">&times;</button>
</div> </div>
@ -457,6 +477,7 @@ export class FolkChoiceConviction extends FolkShape {
</div> </div>
</div> </div>
<div class="results-drawer"></div> <div class="results-drawer"></div>
<div class="settings-panel"></div>
<div class="username-prompt" style="display: none;"> <div class="username-prompt" style="display: none;">
<p>Enter your name to stake:</p> <p>Enter your name to stake:</p>
<input type="text" class="username-input" placeholder="Your name..." /> <input type="text" class="username-input" placeholder="Your name..." />
@ -486,6 +507,17 @@ export class FolkChoiceConviction extends FolkShape {
if (this.#drawerOpen) this.#renderDrawer(); if (this.#drawerOpen) this.#renderDrawer();
}); });
this.#settingsEl = wrapper.querySelector(".settings-panel") as HTMLElement;
const settingsToggle = wrapper.querySelector(".settings-toggle") as HTMLButtonElement;
settingsToggle.addEventListener("click", (e) => {
e.stopPropagation();
this.#settingsOpen = !this.#settingsOpen;
this.#wrapperEl!.classList.toggle("settings-open", this.#settingsOpen);
settingsToggle.classList.toggle("active", this.#settingsOpen);
if (this.#settingsOpen) this.#renderSettings();
else this.#render();
});
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
@ -840,6 +872,78 @@ export class FolkChoiceConviction extends FolkShape {
return div.innerHTML; return div.innerHTML;
} }
#renderSettings() {
if (!this.#settingsEl) return;
const esc = (s: string) => this.#escapeHtml(s);
let h = '<div><div class="settings-label">Title</div>';
h += `<input type="text" class="settings-input settings-title" value="${esc(this.#title)}" /></div>`;
h += '<div><div class="settings-label">Options</div>';
for (let i = 0; i < this.#options.length; i++) {
const opt = this.#options[i];
h += `<div class="settings-item" data-idx="${i}">`;
h += `<input type="color" class="opt-color" value="${opt.color}" />`;
h += `<input type="text" class="opt-label" value="${esc(opt.label)}" />`;
h += `<button class="remove-btn" title="Remove">&times;</button></div>`;
}
h += '</div>';
const uniqueStakers = new Set(this.#stakes.map((s) => s.userId)).size;
h += '<div><div class="settings-label">Danger Zone</div>';
h += `<button class="settings-danger clear-data-btn">Reset all stakes (${uniqueStakers} participant${uniqueStakers !== 1 ? "s" : ""})</button></div>`;
h += '<button class="settings-done">Done</button>';
this.#settingsEl.innerHTML = h;
const stop = (e: Event) => e.stopPropagation();
const titleInput = this.#settingsEl.querySelector(".settings-title") as HTMLInputElement;
titleInput.addEventListener("click", stop);
titleInput.addEventListener("input", () => {
this.#title = titleInput.value;
this.#wrapperEl!.querySelector(".title-text")!.textContent = this.#title;
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#settingsEl.querySelectorAll(".opt-label").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("click", stop);
input.addEventListener("input", () => {
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
this.#options[idx].label = input.value;
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelectorAll(".opt-color").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("click", stop);
input.addEventListener("input", () => {
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
this.#options[idx].color = input.value;
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelectorAll(".remove-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).closest(".settings-item")!.getAttribute("data-idx")!);
const removedId = this.#options[idx].id;
this.#options.splice(idx, 1);
this.#stakes = this.#stakes.filter((s) => s.optionId !== removedId);
this.#renderSettings();
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelector(".clear-data-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.#stakes = [];
this.#renderSettings();
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#settingsEl.querySelector(".settings-done")!.addEventListener("click", (e) => {
e.stopPropagation();
this.#settingsOpen = false;
this.#wrapperEl!.classList.remove("settings-open");
this.#wrapperEl!.querySelector(".settings-toggle")!.classList.remove("active");
this.#render();
});
}
override toJSON() { override toJSON() {
return { return {
...super.toJSON(), ...super.toJSON(),

View File

@ -315,6 +315,22 @@ const styles = css`
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; } .participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drawer-toggle.active { background: rgba(255,255,255,0.3); } .drawer-toggle.active { background: rgba(255,255,255,0.3); }
.settings-toggle.active { background: rgba(255,255,255,0.3); }
.settings-panel { display: none; flex-direction: column; gap: 12px; padding: 12px; overflow-y: auto; height: calc(100% - 36px); }
.settings-open .settings-panel { display: flex; }
.settings-open .body { display: none !important; }
.settings-open .results-drawer { display: none !important; }
.settings-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; }
.settings-input { width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; }
.settings-input:focus { border-color: #4f46e5; }
.settings-item { display: flex; align-items: center; gap: 6px; padding: 4px 0; }
.settings-item input[type="text"] { flex: 1; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; min-width: 0; }
.settings-item .remove-btn { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; }
.settings-item .remove-btn:hover { color: #ef4444; background: #fef2f2; }
.settings-danger { background: none; border: 1px solid #fca5a5; color: #ef4444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 4px; }
.settings-danger:hover { background: #fef2f2; }
.settings-done { background: #4f46e5; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-size: 12px; font-weight: 500; width: 100%; margin-top: auto; }
.settings-done:hover { background: #4338ca; }
`; `;
// -- Data types -- // -- Data types --
@ -537,6 +553,7 @@ export class FolkChoiceRank extends FolkShape {
#userName = ""; #userName = "";
#activeTab: "rank" | "results" = "rank"; #activeTab: "rank" | "results" = "rank";
#drawerOpen = false; #drawerOpen = false;
#settingsOpen = false;
// Drag state // Drag state
#dragIdx: number | null = null; #dragIdx: number | null = null;
@ -548,6 +565,7 @@ export class FolkChoiceRank extends FolkShape {
#rankPanel: HTMLElement | null = null; #rankPanel: HTMLElement | null = null;
#resultsPanel: HTMLElement | null = null; #resultsPanel: HTMLElement | null = null;
#drawerEl: HTMLElement | null = null; #drawerEl: HTMLElement | null = null;
#settingsEl: HTMLElement | null = null;
get title() { return this.#title; } get title() { return this.#title; }
set title(v: string) { this.#title = v; this.requestUpdate("title"); } set title(v: string) { this.#title = v; this.requestUpdate("title"); }
@ -628,6 +646,7 @@ export class FolkChoiceRank extends FolkShape {
<span class="title-text">Rank</span> <span class="title-text">Rank</span>
</span> </span>
<div class="header-actions"> <div class="header-actions">
<button class="settings-toggle" title="Settings">&#x2699;</button>
<button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button> <button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button>
<button class="close-btn" title="Close">&times;</button> <button class="close-btn" title="Close">&times;</button>
</div> </div>
@ -645,6 +664,7 @@ export class FolkChoiceRank extends FolkShape {
</div> </div>
</div> </div>
<div class="results-drawer"></div> <div class="results-drawer"></div>
<div class="settings-panel"></div>
<div class="username-prompt" style="display: none;"> <div class="username-prompt" style="display: none;">
<p>Enter your name to rank:</p> <p>Enter your name to rank:</p>
<input type="text" class="username-input" placeholder="Your name..." /> <input type="text" class="username-input" placeholder="Your name..." />
@ -671,6 +691,17 @@ export class FolkChoiceRank extends FolkShape {
if (this.#drawerOpen) this.#renderDrawer(); if (this.#drawerOpen) this.#renderDrawer();
}); });
this.#settingsEl = wrapper.querySelector(".settings-panel") as HTMLElement;
const settingsToggle = wrapper.querySelector(".settings-toggle") as HTMLButtonElement;
settingsToggle.addEventListener("click", (e) => {
e.stopPropagation();
this.#settingsOpen = !this.#settingsOpen;
this.#wrapperEl!.classList.toggle("settings-open", this.#settingsOpen);
settingsToggle.classList.toggle("active", this.#settingsOpen);
if (this.#settingsOpen) this.#renderSettings();
else this.#render();
});
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
@ -975,6 +1006,72 @@ export class FolkChoiceRank extends FolkShape {
return div.innerHTML; return div.innerHTML;
} }
#renderSettings() {
if (!this.#settingsEl) return;
const esc = (s: string) => this.#escapeHtml(s);
let h = '<div><div class="settings-label">Title</div>';
h += `<input type="text" class="settings-input settings-title" value="${esc(this.#title)}" /></div>`;
h += '<div><div class="settings-label">Options</div>';
for (let i = 0; i < this.#options.length; i++) {
const opt = this.#options[i];
h += `<div class="settings-item" data-idx="${i}">`;
h += `<input type="text" class="opt-label" value="${esc(opt.label)}" />`;
h += `<button class="remove-btn" title="Remove">&times;</button></div>`;
}
h += '</div>';
h += '<div><div class="settings-label">Danger Zone</div>';
h += `<button class="settings-danger clear-data-btn">Reset all rankings (${this.#rankings.length} voter${this.#rankings.length !== 1 ? "s" : ""})</button></div>`;
h += '<button class="settings-done">Done</button>';
this.#settingsEl.innerHTML = h;
const stop = (e: Event) => e.stopPropagation();
const titleInput = this.#settingsEl.querySelector(".settings-title") as HTMLInputElement;
titleInput.addEventListener("click", stop);
titleInput.addEventListener("input", () => {
this.#title = titleInput.value;
this.#wrapperEl!.querySelector(".title-text")!.textContent = this.#title;
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#settingsEl.querySelectorAll(".opt-label").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("click", stop);
input.addEventListener("input", () => {
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
this.#options[idx].label = input.value;
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelectorAll(".remove-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).closest(".settings-item")!.getAttribute("data-idx")!);
const removedId = this.#options[idx].id;
this.#options.splice(idx, 1);
for (const r of this.#rankings) {
r.ordering = r.ordering.filter((id) => id !== removedId);
}
this.#myOrdering = this.#myOrdering.filter((id) => id !== removedId);
this.#renderSettings();
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelector(".clear-data-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.#rankings = [];
this.#myOrdering = this.#options.map((o) => o.id);
this.#renderSettings();
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#settingsEl.querySelector(".settings-done")!.addEventListener("click", (e) => {
e.stopPropagation();
this.#settingsOpen = false;
this.#wrapperEl!.classList.remove("settings-open");
this.#wrapperEl!.querySelector(".settings-toggle")!.classList.remove("active");
this.#syncMyOrdering();
this.#render();
});
}
override toJSON() { override toJSON() {
return { return {
...super.toJSON(), ...super.toJSON(),

View File

@ -284,6 +284,25 @@ const styles = css`
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; } .participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drawer-toggle.active { background: rgba(255,255,255,0.3); } .drawer-toggle.active { background: rgba(255,255,255,0.3); }
.settings-toggle.active { background: rgba(255,255,255,0.3); }
.settings-panel { display: none; flex-direction: column; gap: 12px; padding: 12px; overflow-y: auto; height: calc(100% - 36px); }
.settings-open .settings-panel { display: flex; }
.settings-open .body { display: none !important; }
.settings-open .results-drawer { display: none !important; }
.settings-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; }
.settings-input { width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; }
.settings-input:focus { border-color: #059669; }
.settings-item { display: flex; align-items: center; gap: 6px; padding: 4px 0; }
.settings-item input[type="text"] { flex: 1; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; min-width: 0; }
.settings-item input[type="range"] { width: 60px; height: 4px; -webkit-appearance: none; appearance: none; background: #e2e8f0; border-radius: 2px; outline: none; cursor: pointer; }
.settings-item input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #059669; cursor: pointer; }
.settings-item .weight-val { font-size: 10px; font-weight: 600; color: #059669; min-width: 12px; text-align: center; }
.settings-item .remove-btn { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; }
.settings-item .remove-btn:hover { color: #ef4444; background: #fef2f2; }
.settings-danger { background: none; border: 1px solid #fca5a5; color: #ef4444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 4px; }
.settings-danger:hover { background: #fef2f2; }
.settings-done { background: #059669; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-size: 12px; font-weight: 500; width: 100%; margin-top: auto; }
.settings-done:hover { background: #047857; }
`; `;
// -- Data types -- // -- Data types --
@ -480,6 +499,7 @@ export class FolkChoiceSpider extends FolkShape {
#userName = ""; #userName = "";
#selectedOptionId = ""; #selectedOptionId = "";
#drawerOpen = false; #drawerOpen = false;
#settingsOpen = false;
// DOM refs // DOM refs
#wrapperEl: HTMLElement | null = null; #wrapperEl: HTMLElement | null = null;
@ -490,6 +510,7 @@ export class FolkChoiceSpider extends FolkShape {
#summaryEl: HTMLElement | null = null; #summaryEl: HTMLElement | null = null;
#optionTabsEl: HTMLElement | null = null; #optionTabsEl: HTMLElement | null = null;
#drawerEl: HTMLElement | null = null; #drawerEl: HTMLElement | null = null;
#settingsEl: HTMLElement | null = null;
get title() { return this.#title; } get title() { return this.#title; }
set title(v: string) { this.#title = v; this.requestUpdate("title"); } set title(v: string) { this.#title = v; this.requestUpdate("title"); }
@ -570,6 +591,7 @@ export class FolkChoiceSpider extends FolkShape {
<span class="title-text">Spider</span> <span class="title-text">Spider</span>
</span> </span>
<div class="header-actions"> <div class="header-actions">
<button class="settings-toggle" title="Settings">&#x2699;</button>
<button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button> <button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button>
<button class="close-btn" title="Close">&times;</button> <button class="close-btn" title="Close">&times;</button>
</div> </div>
@ -592,6 +614,7 @@ export class FolkChoiceSpider extends FolkShape {
</div> </div>
</div> </div>
<div class="results-drawer"></div> <div class="results-drawer"></div>
<div class="settings-panel"></div>
<div class="username-prompt" style="display: none;"> <div class="username-prompt" style="display: none;">
<p>Enter your name to score:</p> <p>Enter your name to score:</p>
<input type="text" class="username-input" placeholder="Your name..." /> <input type="text" class="username-input" placeholder="Your name..." />
@ -621,6 +644,17 @@ export class FolkChoiceSpider extends FolkShape {
if (this.#drawerOpen) this.#renderDrawer(); if (this.#drawerOpen) this.#renderDrawer();
}); });
this.#settingsEl = wrapper.querySelector(".settings-panel") as HTMLElement;
const settingsToggle = wrapper.querySelector(".settings-toggle") as HTMLButtonElement;
settingsToggle.addEventListener("click", (e) => {
e.stopPropagation();
this.#settingsOpen = !this.#settingsOpen;
this.#wrapperEl!.classList.toggle("settings-open", this.#settingsOpen);
settingsToggle.classList.toggle("active", this.#settingsOpen);
if (this.#settingsOpen) this.#renderSettings();
else this.#render();
});
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
@ -948,6 +982,110 @@ export class FolkChoiceSpider extends FolkShape {
return div.innerHTML; return div.innerHTML;
} }
#renderSettings() {
if (!this.#settingsEl) return;
const esc = (s: string) => this.#escapeHtml(s);
let h = '<div><div class="settings-label">Title</div>';
h += `<input type="text" class="settings-input settings-title" value="${esc(this.#title)}" /></div>`;
h += '<div><div class="settings-label">Options</div>';
for (let i = 0; i < this.#options.length; i++) {
const opt = this.#options[i];
h += `<div class="settings-item" data-idx="${i}" data-kind="opt">`;
h += `<input type="text" class="opt-label" value="${esc(opt.label)}" />`;
h += `<button class="remove-btn" title="Remove">&times;</button></div>`;
}
h += '</div>';
h += '<div><div class="settings-label">Criteria &amp; Weights</div>';
for (let i = 0; i < this.#criteria.length; i++) {
const c = this.#criteria[i];
h += `<div class="settings-item" data-idx="${i}" data-kind="crit">`;
h += `<input type="text" class="crit-label" value="${esc(c.label)}" />`;
h += `<input type="range" class="crit-weight" min="1" max="5" value="${c.weight}" />`;
h += `<span class="weight-val">${c.weight}</span>`;
h += `<button class="remove-btn" title="Remove">&times;</button></div>`;
}
h += '</div>';
const allUsers = new Set(this.#scores.map((s) => s.userId));
h += '<div><div class="settings-label">Danger Zone</div>';
h += `<button class="settings-danger clear-data-btn">Reset all scores (${allUsers.size} participant${allUsers.size !== 1 ? "s" : ""})</button></div>`;
h += '<button class="settings-done">Done</button>';
this.#settingsEl.innerHTML = h;
const stop = (e: Event) => e.stopPropagation();
const titleInput = this.#settingsEl.querySelector(".settings-title") as HTMLInputElement;
titleInput.addEventListener("click", stop);
titleInput.addEventListener("input", () => {
this.#title = titleInput.value;
this.#wrapperEl!.querySelector(".title-text")!.textContent = this.#title;
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#settingsEl.querySelectorAll(".opt-label").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("click", stop);
input.addEventListener("input", () => {
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
this.#options[idx].label = input.value;
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelectorAll(".crit-label").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("click", stop);
input.addEventListener("input", () => {
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
this.#criteria[idx].label = input.value;
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelectorAll(".crit-weight").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("click", stop);
input.addEventListener("pointerdown", stop);
input.addEventListener("input", () => {
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
this.#criteria[idx].weight = parseInt(input.value);
const valEl = input.parentElement!.querySelector(".weight-val") as HTMLElement;
valEl.textContent = input.value;
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelectorAll(".remove-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const item = (btn as HTMLElement).closest(".settings-item")!;
const idx = parseInt(item.getAttribute("data-idx")!);
const kind = item.getAttribute("data-kind");
if (kind === "opt") {
const removedId = this.#options[idx].id;
this.#options.splice(idx, 1);
this.#scores = this.#scores.filter((s) => s.optionId !== removedId);
if (this.#selectedOptionId === removedId) {
this.#selectedOptionId = this.#options[0]?.id || "";
}
} else {
const removedId = this.#criteria[idx].id;
this.#criteria.splice(idx, 1);
this.#scores = this.#scores.filter((s) => s.criterionId !== removedId);
}
this.#renderSettings();
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelector(".clear-data-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.#scores = [];
this.#renderSettings();
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#settingsEl.querySelector(".settings-done")!.addEventListener("click", (e) => {
e.stopPropagation();
this.#settingsOpen = false;
this.#wrapperEl!.classList.remove("settings-open");
this.#wrapperEl!.querySelector(".settings-toggle")!.classList.remove("active");
this.#render();
});
}
override toJSON() { override toJSON() {
return { return {
...super.toJSON(), ...super.toJSON(),

View File

@ -293,6 +293,24 @@ const styles = css`
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; } .participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.drawer-toggle.active { background: rgba(255,255,255,0.3); } .drawer-toggle.active { background: rgba(255,255,255,0.3); }
.settings-toggle.active { background: rgba(255,255,255,0.3); }
.settings-panel { display: none; flex-direction: column; gap: 12px; padding: 12px; overflow-y: auto; height: calc(100% - 36px); }
.settings-open .settings-panel { display: flex; }
.settings-open .body { display: none !important; }
.settings-open .results-drawer { display: none !important; }
.settings-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; }
.settings-input { width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; }
.settings-input:focus { border-color: #0d9488; }
.settings-item { display: flex; align-items: center; gap: 6px; padding: 4px 0; }
.settings-item input[type="text"] { flex: 1; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; min-width: 0; }
.settings-item input[type="color"] { width: 24px; height: 24px; border: none; border-radius: 4px; padding: 0; cursor: pointer; background: none; }
.settings-item input[type="number"] { width: 60px; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; }
.settings-item .remove-btn { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; }
.settings-item .remove-btn:hover { color: #ef4444; background: #fef2f2; }
.settings-danger { background: none; border: 1px solid #fca5a5; color: #ef4444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 4px; }
.settings-danger:hover { background: #fef2f2; }
.settings-done { background: #0d9488; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-size: 12px; font-weight: 500; width: 100%; margin-top: auto; }
.settings-done:hover { background: #0f766e; }
`; `;
// -- Data types -- // -- Data types --
@ -396,6 +414,7 @@ export class FolkChoiceVote extends FolkShape {
#userId = ""; #userId = "";
#userName = ""; #userName = "";
#drawerOpen = false; #drawerOpen = false;
#settingsOpen = false;
// DOM refs // DOM refs
#wrapperEl: HTMLElement | null = null; #wrapperEl: HTMLElement | null = null;
@ -404,6 +423,7 @@ export class FolkChoiceVote extends FolkShape {
#budgetEl: HTMLElement | null = null; #budgetEl: HTMLElement | null = null;
#votersEl: HTMLElement | null = null; #votersEl: HTMLElement | null = null;
#drawerEl: HTMLElement | null = null; #drawerEl: HTMLElement | null = null;
#settingsEl: HTMLElement | null = null;
get title() { return this.#title; } get title() { return this.#title; }
set title(v: string) { set title(v: string) {
@ -473,6 +493,7 @@ export class FolkChoiceVote extends FolkShape {
<span class="title-text">Poll</span> <span class="title-text">Poll</span>
</span> </span>
<div class="header-actions"> <div class="header-actions">
<button class="settings-toggle" title="Settings">&#x2699;</button>
<button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button> <button class="drawer-toggle" title="Results &amp; Stats">&#x1F4CA;</button>
<button class="close-btn" title="Close">&times;</button> <button class="close-btn" title="Close">&times;</button>
</div> </div>
@ -492,6 +513,7 @@ export class FolkChoiceVote extends FolkShape {
</div> </div>
</div> </div>
<div class="results-drawer"></div> <div class="results-drawer"></div>
<div class="settings-panel"></div>
<div class="username-prompt" style="display: none;"> <div class="username-prompt" style="display: none;">
<p>Enter your name to vote:</p> <p>Enter your name to vote:</p>
<input type="text" class="username-input" placeholder="Your name..." /> <input type="text" class="username-input" placeholder="Your name..." />
@ -520,6 +542,17 @@ export class FolkChoiceVote extends FolkShape {
if (this.#drawerOpen) this.#renderDrawer(); if (this.#drawerOpen) this.#renderDrawer();
}); });
this.#settingsEl = wrapper.querySelector(".settings-panel") as HTMLElement;
const settingsToggle = wrapper.querySelector(".settings-toggle") as HTMLButtonElement;
settingsToggle.addEventListener("click", (e) => {
e.stopPropagation();
this.#settingsOpen = !this.#settingsOpen;
this.#wrapperEl!.classList.toggle("settings-open", this.#settingsOpen);
settingsToggle.classList.toggle("active", this.#settingsOpen);
if (this.#settingsOpen) this.#renderSettings();
else this.#render();
});
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
@ -810,6 +843,88 @@ export class FolkChoiceVote extends FolkShape {
return div.innerHTML; return div.innerHTML;
} }
#renderSettings() {
if (!this.#settingsEl) return;
const esc = (s: string) => this.#escapeHtml(s);
let h = '<div><div class="settings-label">Title</div>';
h += `<input type="text" class="settings-input settings-title" value="${esc(this.#title)}" /></div>`;
h += '<div><div class="settings-label">Options</div>';
for (let i = 0; i < this.#options.length; i++) {
const opt = this.#options[i];
h += `<div class="settings-item" data-idx="${i}">`;
h += `<input type="color" class="opt-color" value="${opt.color}" />`;
h += `<input type="text" class="opt-label" value="${esc(opt.label)}" />`;
h += `<button class="remove-btn" title="Remove">&times;</button></div>`;
}
h += '</div>';
h += '<div><div class="settings-label">Quadratic Budget</div>';
h += `<div class="settings-item"><input type="number" class="settings-budget" value="${this.#budget}" min="1" max="10000" />`;
h += '<span style="font-size:11px;color:#64748b">credits per voter</span></div></div>';
h += '<div><div class="settings-label">Danger Zone</div>';
h += `<button class="settings-danger clear-data-btn">Reset all votes (${this.#votes.length})</button></div>`;
h += '<button class="settings-done">Done</button>';
this.#settingsEl.innerHTML = h;
const stop = (e: Event) => e.stopPropagation();
const titleInput = this.#settingsEl.querySelector(".settings-title") as HTMLInputElement;
titleInput.addEventListener("click", stop);
titleInput.addEventListener("input", () => {
this.#title = titleInput.value;
this.#wrapperEl!.querySelector(".title-text")!.textContent = this.#title;
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#settingsEl.querySelectorAll(".opt-label").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("click", stop);
input.addEventListener("input", () => {
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
this.#options[idx].label = input.value;
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelectorAll(".opt-color").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("click", stop);
input.addEventListener("input", () => {
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
this.#options[idx].color = input.value;
this.dispatchEvent(new CustomEvent("content-change"));
});
});
this.#settingsEl.querySelectorAll(".remove-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).closest(".settings-item")!.getAttribute("data-idx")!);
const removedId = this.#options[idx].id;
this.#options.splice(idx, 1);
for (const v of this.#votes) delete v.allocations[removedId];
this.#renderSettings();
this.dispatchEvent(new CustomEvent("content-change"));
});
});
const budgetInput = this.#settingsEl.querySelector(".settings-budget") as HTMLInputElement;
if (budgetInput) {
budgetInput.addEventListener("click", stop);
budgetInput.addEventListener("input", () => {
this.#budget = parseInt(budgetInput.value) || 100;
this.dispatchEvent(new CustomEvent("content-change"));
});
}
this.#settingsEl.querySelector(".clear-data-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.#votes = [];
this.#renderSettings();
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#settingsEl.querySelector(".settings-done")!.addEventListener("click", (e) => {
e.stopPropagation();
this.#settingsOpen = false;
this.#wrapperEl!.classList.remove("settings-open");
this.#wrapperEl!.querySelector(".settings-toggle")!.classList.remove("active");
this.#render();
});
}
override toJSON() { override toJSON() {
return { return {
...super.toJSON(), ...super.toJSON(),