From b52aa8298b85de1a6f78b05b9899d5bae611a704 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 13:19:45 -0800 Subject: [PATCH] feat: conviction voting component, rNotes refinements, space visibility endpoints - Add folk-choice-conviction library and register in lib/index - Refactor rNotes app layout and interaction - Space visibility normalization in server/spaces - Minor canvas.html tweaks Co-Authored-By: Claude Opus 4.6 --- lib/folk-choice-conviction.ts | 723 ++++++++++++++++++++ lib/index.ts | 1 + modules/rnotes/components/folk-notes-app.ts | 282 +++++--- server/spaces.ts | 20 +- website/canvas.html | 5 +- 5 files changed, 913 insertions(+), 118 deletions(-) create mode 100644 lib/folk-choice-conviction.ts diff --git a/lib/folk-choice-conviction.ts b/lib/folk-choice-conviction.ts new file mode 100644 index 0000000..fb23ac4 --- /dev/null +++ b/lib/folk-choice-conviction.ts @@ -0,0 +1,723 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const USER_ID_KEY = "folk-choice-userid"; +const USER_NAME_KEY = "folk-choice-username"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 340px; + min-height: 380px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #d97706; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions { + display: flex; + gap: 4px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + height: calc(100% - 36px); + overflow: hidden; + } + + .options-list { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + } + + .option-row { + display: flex; + align-items: center; + gap: 6px; + padding: 8px; + border-radius: 6px; + margin-bottom: 6px; + background: #fffbeb; + border: 1px solid #fde68a; + position: relative; + overflow: hidden; + } + + .conviction-bg { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: 6px; + opacity: 0.12; + transition: width 0.4s ease; + } + + .option-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + z-index: 1; + } + + .option-label { + flex: 1; + font-size: 13px; + color: #1e293b; + z-index: 1; + } + + .stake-controls { + display: flex; + align-items: center; + gap: 4px; + z-index: 1; + } + + .stake-btn { + width: 22px; + height: 22px; + border: 1px solid #fbbf24; + border-radius: 4px; + background: white; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + color: #92400e; + } + + .stake-btn:hover { background: #fef3c7; } + .stake-btn:disabled { opacity: 0.3; cursor: default; } + + .stake-count { + font-size: 12px; + font-variant-numeric: tabular-nums; + min-width: 14px; + text-align: center; + color: #92400e; + font-weight: 600; + z-index: 1; + } + + .option-conviction { + font-size: 11px; + color: #d97706; + font-weight: 600; + font-variant-numeric: tabular-nums; + min-width: 40px; + text-align: right; + z-index: 1; + } + + .option-time { + font-size: 10px; + color: #94a3b8; + min-width: 32px; + text-align: right; + z-index: 1; + } + + .weight-bar { + padding: 4px 12px; + font-size: 11px; + color: #64748b; + border-top: 1px solid #e2e8f0; + text-align: center; + } + + .weight-bar .used { + font-weight: 600; + color: #d97706; + } + + .voters-count { + padding: 4px 12px; + font-size: 11px; + color: #94a3b8; + text-align: center; + } + + .add-form { + display: flex; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .add-input { + flex: 1; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + outline: none; + } + + .add-input:focus { border-color: #d97706; } + + .add-btn { + background: #d97706; + color: white; + border: none; + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + } + + .add-btn:hover { background: #b45309; } + + .username-prompt { + padding: 16px; + text-align: center; + } + + .username-prompt p { + font-size: 13px; + color: #64748b; + margin: 0 0 8px; + } + + .username-input { + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + outline: none; + width: 100%; + box-sizing: border-box; + margin-bottom: 8px; + } + + .username-btn { + background: #d97706; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-weight: 500; + font-size: 13px; + } + + .wrapper { position: relative; height: 100%; } + .results-drawer { + position: absolute; top: 0; left: 100%; width: 300px; height: 100%; + background: white; border-radius: 0 8px 8px 0; + box-shadow: 4px 0 12px rgba(0,0,0,0.08); + overflow-y: auto; display: none; flex-direction: column; + font-size: 12px; z-index: 10; + } + .drawer-open .results-drawer { display: flex; } + .drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; } + .drawer-heading { + font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; + } + .stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; } + .stat-label { color: #64748b; } + .stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; } + .drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } + .drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; } + .drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; } + .drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } + .drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; } + .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; } + .drawer-toggle.active { background: rgba(255,255,255,0.3); } +`; + +// -- Data types -- + +export interface ConvictionOption { + id: string; + label: string; + color: string; +} + +export interface ConvictionStake { + userId: string; + userName: string; + optionId: string; + weight: number; + since: number; +} + +// -- Pure aggregation functions -- + +export function convictionScore( + stakes: ConvictionStake[], + optionId: string, + now: number, +): number { + let total = 0; + for (const s of stakes) { + if (s.optionId !== optionId) continue; + total += s.weight * Math.max(0, now - s.since) / 3600000; + } + return total; +} + +export function convictionVelocity( + stakes: ConvictionStake[], + optionId: string, +): number { + let total = 0; + for (const s of stakes) { + if (s.optionId !== optionId) continue; + total += s.weight; + } + return total; +} + +// -- Component -- + +declare global { + interface HTMLElementTagNameMap { + "folk-choice-conviction": FolkChoiceConviction; + } +} + +const DEFAULT_COLORS = ["#f59e0b", "#3b82f6", "#22c55e", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; + +export class FolkChoiceConviction extends FolkShape { + static override tagName = "folk-choice-conviction"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Conviction Ranking"; + #options: ConvictionOption[] = []; + #stakes: ConvictionStake[] = []; + #userId = ""; + #userName = ""; + #drawerOpen = false; + #tickInterval: ReturnType | null = null; + + // DOM refs + #wrapperEl: HTMLElement | null = null; + #bodyEl: HTMLElement | null = null; + #optionsEl: HTMLElement | null = null; + #weightEl: HTMLElement | null = null; + #votersEl: HTMLElement | null = null; + #drawerEl: HTMLElement | null = null; + + get title() { return this.#title; } + set title(v: string) { this.#title = v; this.requestUpdate("title"); } + + get options() { return this.#options; } + set options(v: ConvictionOption[]) { + this.#options = v; + this.#render(); + this.requestUpdate("options"); + } + + get stakes() { return this.#stakes; } + set stakes(v: ConvictionStake[]) { + this.#stakes = v; + this.#render(); + this.requestUpdate("stakes"); + } + + #ensureIdentity(): boolean { + if (this.#userId && this.#userName) return true; + this.#userId = localStorage.getItem(USER_ID_KEY) || ""; + this.#userName = localStorage.getItem(USER_NAME_KEY) || localStorage.getItem("rspace-username") || ""; + if (!this.#userId) { + this.#userId = crypto.randomUUID().slice(0, 8); + localStorage.setItem(USER_ID_KEY, this.#userId); + } + return !!this.#userName; + } + + #setUserName(name: string) { + this.#userName = name; + localStorage.setItem(USER_NAME_KEY, name); + localStorage.setItem("rspace-username", name); + } + + #getMyStake(optionId: string): ConvictionStake | undefined { + return this.#stakes.find((s) => s.userId === this.#userId && s.optionId === optionId); + } + + #setStake(optionId: string, delta: number) { + if (!this.#ensureIdentity()) return; + + const existing = this.#getMyStake(optionId); + const currentWeight = existing ? existing.weight : 0; + const newWeight = Math.max(0, Math.min(10, currentWeight + delta)); + + if (newWeight === 0) { + // Remove stake + this.#stakes = this.#stakes.filter( + (s) => !(s.userId === this.#userId && s.optionId === optionId), + ); + } else if (existing) { + // Update — reset conviction timer on weight change + existing.weight = newWeight; + existing.since = Date.now(); + } else { + // New stake + this.#stakes.push({ + userId: this.#userId, + userName: this.#userName, + optionId, + weight: newWeight, + since: Date.now(), + }); + } + + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.#ensureIdentity(); + + const wrapper = document.createElement("div"); + wrapper.className = "wrapper"; + wrapper.innerHTML = html` +
+ + + Conviction + +
+ + +
+
+
+
+
+
+
+ + +
+
+
+ + `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) containerDiv.replaceWith(wrapper); + + this.#wrapperEl = wrapper; + this.#bodyEl = wrapper.querySelector(".body") as HTMLElement; + this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement; + this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement; + this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement; + this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement; + const titleEl = wrapper.querySelector(".title-text") as HTMLElement; + + const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement; + drawerToggle.addEventListener("click", (e) => { + e.stopPropagation(); + this.#drawerOpen = !this.#drawerOpen; + this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen); + drawerToggle.classList.toggle("active", this.#drawerOpen); + if (this.#drawerOpen) this.#renderDrawer(); + }); + + const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement; + const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement; + const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement; + const addInput = wrapper.querySelector(".add-input") as HTMLInputElement; + const addBtn = wrapper.querySelector(".add-btn") as HTMLButtonElement; + + if (!this.#userName) { + this.#bodyEl.style.display = "none"; + usernamePrompt.style.display = "block"; + } + + const submitName = () => { + const name = usernameInput.value.trim(); + if (name) { + this.#setUserName(name); + this.#bodyEl!.style.display = "flex"; + usernamePrompt.style.display = "none"; + this.#render(); + } + }; + usernameBtn.addEventListener("click", (e) => { e.stopPropagation(); submitName(); }); + usernameInput.addEventListener("click", (e) => e.stopPropagation()); + usernameInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") submitName(); }); + + // Add option + const addOption = () => { + const label = addInput.value.trim(); + if (!label) return; + this.#options.push({ + id: `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + label, + color: DEFAULT_COLORS[this.#options.length % DEFAULT_COLORS.length], + }); + addInput.value = ""; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + }; + addBtn.addEventListener("click", (e) => { e.stopPropagation(); addOption(); }); + addInput.addEventListener("click", (e) => e.stopPropagation()); + addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOption(); }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + if (this.#title) titleEl.textContent = this.#title; + + // Live conviction update + this.#tickInterval = setInterval(() => this.#render(), 10000); + + this.#render(); + return root; + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#tickInterval) { + clearInterval(this.#tickInterval); + this.#tickInterval = null; + } + } + + #render() { + this.#renderOptions(); + if (this.#drawerOpen) this.#renderDrawer(); + } + + #renderOptions() { + if (!this.#optionsEl) return; + const now = Date.now(); + const convictions = this.#options.map((opt) => ({ + id: opt.id, + score: convictionScore(this.#stakes, opt.id, now), + })); + const maxConv = Math.max(1, ...convictions.map((c) => c.score)); + const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size; + + this.#optionsEl.innerHTML = this.#options + .map((opt) => { + const conv = convictions.find((c) => c.id === opt.id)!; + const barWidth = (conv.score / maxConv) * 100; + const myStake = this.#getMyStake(opt.id); + const myWeight = myStake ? myStake.weight : 0; + const timeHeld = myStake ? this.#formatDuration(now - myStake.since) : ""; + + return ` +
+
+ + ${this.#escapeHtml(opt.label)} +
+ + ${myWeight} + +
+ ${timeHeld} + ${this.#formatConviction(conv.score)} +
+ `; + }) + .join(""); + + // Wire buttons + this.#optionsEl.querySelectorAll(".stake-plus").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#setStake((btn as HTMLElement).dataset.opt!, 1); + }); + }); + this.#optionsEl.querySelectorAll(".stake-minus").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#setStake((btn as HTMLElement).dataset.opt!, -1); + }); + }); + + // Weight bar + if (this.#weightEl) { + const totalWeight = this.#stakes + .filter((s) => s.userId === this.#userId) + .reduce((sum, s) => sum + s.weight, 0); + this.#weightEl.innerHTML = `Your weight: ${totalWeight} across ${this.#stakes.filter((s) => s.userId === this.#userId).length} option${this.#stakes.filter((s) => s.userId === this.#userId).length !== 1 ? "s" : ""}`; + } + + // Voters count + if (this.#votersEl) { + this.#votersEl.textContent = uniqueParticipants === 0 + ? "No stakes yet" + : `${uniqueParticipants} participant${uniqueParticipants !== 1 ? "s" : ""}`; + } + } + + #renderDrawer() { + if (!this.#drawerEl) return; + const now = Date.now(); + const optMap = new Map(this.#options.map((o) => [o.id, o])); + + // Group Results: conviction leaderboard + const convictions = this.#options.map((opt) => ({ + id: opt.id, + label: opt.label, + color: opt.color, + score: convictionScore(this.#stakes, opt.id, now), + velocity: convictionVelocity(this.#stakes, opt.id), + rawWeight: this.#stakes.filter((s) => s.optionId === opt.id).reduce((sum, s) => sum + s.weight, 0), + })); + convictions.sort((a, b) => b.score - a.score); + const maxConv = Math.max(1, ...convictions.map((c) => c.score)); + + let resultsHtml = '
Conviction Leaderboard
'; + for (const c of convictions) { + const pct = (c.score / maxConv) * 100; + resultsHtml += `
+ ${this.#escapeHtml(c.label)} +
+ ${this.#formatConviction(c.score)} +
`; + } + resultsHtml += "
"; + + // Statistics + const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size; + const totalConv = convictions.reduce((sum, c) => sum + c.score, 0); + const totalVelocity = convictions.reduce((sum, c) => sum + c.velocity, 0); + + let statsHtml = '
Statistics
'; + statsHtml += `
Participants${uniqueParticipants}
`; + statsHtml += `
Total conviction${this.#formatConviction(totalConv)}
`; + statsHtml += `
Velocity (wt/hr)${totalVelocity.toFixed(1)}
`; + + // Raw weight vs conviction rank comparison + const byRawWeight = [...convictions].sort((a, b) => b.rawWeight - a.rawWeight); + const byConviction = convictions; // already sorted + let rankDiff = false; + for (let i = 0; i < convictions.length; i++) { + if (byRawWeight[i]?.id !== byConviction[i]?.id) { rankDiff = true; break; } + } + if (rankDiff && convictions.length >= 2) { + statsHtml += '
Raw Weight vs Conviction
'; + for (const c of convictions) { + const rawRank = byRawWeight.findIndex((r) => r.id === c.id) + 1; + const convRank = byConviction.findIndex((r) => r.id === c.id) + 1; + const diff = rawRank - convRank; + const arrow = diff > 0 ? `↑${diff}` : diff < 0 ? `↓${Math.abs(diff)}` : "="; + statsHtml += `
${this.#escapeHtml(c.label)}#${convRank} (${arrow})
`; + } + statsHtml += "
"; + } + statsHtml += "
"; + + // Participants + const userStakes = new Map(); + for (const s of this.#stakes) { + const u = userStakes.get(s.userId) || { name: s.userName, totalWeight: 0, totalConv: 0, optCount: 0 }; + u.name = s.userName; + u.totalWeight += s.weight; + u.totalConv += s.weight * Math.max(0, now - s.since) / 3600000; + u.optCount++; + userStakes.set(s.userId, u); + } + + let participantsHtml = '
Participants
'; + for (const [, u] of userStakes) { + participantsHtml += `
+ + ${this.#escapeHtml(u.name)} + wt:${u.totalWeight} conv:${this.#formatConviction(u.totalConv)} +
`; + } + participantsHtml += "
"; + + this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml; + } + + #formatConviction(score: number): string { + if (score < 1) return score.toFixed(2); + if (score < 100) return score.toFixed(1); + return Math.round(score).toString(); + } + + #formatDuration(ms: number): string { + if (ms < 60000) return "<1m"; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m`; + if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`; + return `${Math.floor(ms / 86400000)}d`; + } + + #timeAgo(ts: number): string { + const diff = Date.now() - ts; + if (diff < 60000) return "just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 86400000)}d ago`; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-choice-conviction", + title: this.#title, + options: this.#options, + stakes: this.#stakes, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 2ac5d62..a9b5246 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -70,6 +70,7 @@ export * from "./folk-social-post"; export * from "./folk-choice-vote"; export * from "./folk-choice-rank"; export * from "./folk-choice-spider"; +export * from "./folk-choice-conviction"; // Nested Space Shape export * from "./folk-canvas"; diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index a8434e0..3170ace 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -1173,7 +1173,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF this.navZone.innerHTML = `

- ${this.esc(nb.title)}${syncBadge} + ${this.esc(nb.title)}${syncBadge}
`; return; @@ -1364,197 +1364,267 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF private getStyles(): string { return ` - :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; } + :host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); } * { box-sizing: border-box; } + /* ── Navigation ── */ .rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; } - .rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; } - .rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); } - .rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; } - .rapp-nav__btn:hover { background: #6366f1; } + .rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; transition: all 0.15s; } + .rapp-nav__back:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); } + .rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; } + .rapp-nav__btn:hover { background: var(--rs-primary-hover); } + /* ── Search ── */ .search-bar { width: 100%; padding: 10px 14px; border-radius: 8px; - border: 1px solid #444; background: #2a2a3e; color: #e0e0e0; - font-size: 14px; margin-bottom: 16px; + border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); + font-size: 14px; margin-bottom: 16px; transition: border-color 0.15s; } - .search-bar:focus { border-color: #6366f1; outline: none; } + .search-bar:focus { border-color: var(--rs-primary); outline: none; } + .search-results-info { margin-bottom: 12px; font-size: 13px; color: var(--rs-text-secondary); } - .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } + /* ── Notebook Grid ── */ + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; } .notebook-card { - border-radius: 10px; padding: 16px; cursor: pointer; - border: 2px solid transparent; transition: border-color 0.2s; + position: relative; overflow: hidden; + border-radius: 12px; padding: 16px 16px 16px 20px; cursor: pointer; + border: 1px solid var(--rs-card-border); background: var(--rs-card-bg); + transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s; min-height: 120px; display: flex; flex-direction: column; justify-content: space-between; } - .notebook-card:hover { border-color: rgba(255,255,255,0.2); } - .notebook-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; } - .notebook-meta { font-size: 12px; opacity: 0.7; } + .notebook-card:hover { border-color: var(--rs-border); box-shadow: var(--rs-shadow-sm); transform: translateY(-1px); } + .notebook-card__accent { position: absolute; top: 0; left: 0; width: 4px; height: 100%; border-radius: 12px 0 0 12px; } + .notebook-card__body { flex: 1; } + .notebook-card__title { font-size: 15px; font-weight: 600; margin-bottom: 4px; color: var(--rs-text-primary); } + .notebook-card__desc { font-size: 12px; color: var(--rs-text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } + .notebook-card__footer { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-top: 8px; } + /* ── Note Items ── */ .note-item { - background: #1e1e2e; border: 1px solid #333; border-radius: 8px; - padding: 12px 16px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 10px; + padding: 14px 16px; margin-bottom: 8px; cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; display: flex; gap: 12px; align-items: flex-start; } - .note-item:hover { border-color: #555; } - .note-icon { font-size: 20px; flex-shrink: 0; } - .note-body { flex: 1; min-width: 0; } - .note-title { font-size: 14px; font-weight: 600; } - .note-preview { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .note-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 8px; } - .tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #333; color: #aaa; font-size: 10px; } - .pinned { color: #f59e0b; } - - .editable-title { - background: transparent; border: none; color: #e2e8f0; font-family: inherit; - font-size: 22px; font-weight: 700; width: 100%; outline: none; - padding: 8px 0; margin-bottom: 4px; + .note-item:hover { border-color: var(--rs-border); box-shadow: var(--rs-shadow-sm); } + .note-item__icon { + font-size: 18px; flex-shrink: 0; width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + background: var(--rs-bg-surface-raised); border-radius: 8px; } - .editable-title:focus { border-bottom: 2px solid #6366f1; } - .editable-title::placeholder { color: #555; } + .note-item__body { flex: 1; min-width: 0; } + .note-item__title { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); } + .note-item__pin { color: var(--rs-warning); } + .note-item__preview { + font-size: 12px; color: var(--rs-text-muted); margin-top: 3px; line-height: 1.4; + overflow: hidden; text-overflow: ellipsis; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + } + .note-item__meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; display: flex; gap: 8px; align-items: center; } + .tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--rs-bg-surface-raised); color: var(--rs-text-secondary); font-size: 10px; } + /* ── Editor Title ── */ + .editable-title { + background: transparent; border: none; border-bottom: 2px solid transparent; + color: var(--rs-text-primary); font-family: inherit; + font-size: 22px; font-weight: 700; width: 100%; outline: none; + padding: 8px 0; margin-bottom: 4px; transition: border-color 0.15s; + } + .editable-title:focus { border-bottom-color: var(--rs-primary); } + .editable-title::placeholder { color: var(--rs-text-muted); } + + /* ── Sync Badge ── */ .sync-badge { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-left: 8px; vertical-align: middle; } - .sync-badge.connected { background: #10b981; } - .sync-badge.disconnected { background: #ef4444; } + .sync-badge.connected { background: var(--rs-success); } + .sync-badge.disconnected { background: var(--rs-error); } - .empty { text-align: center; color: #666; padding: 40px; } - .loading { text-align: center; color: #888; padding: 40px; } - .error { text-align: center; color: #ef5350; padding: 20px; } + /* ── State Messages ── */ + .empty { text-align: center; color: var(--rs-text-muted); padding: 40px; } + .loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; } + .error { text-align: center; color: var(--rs-error); padding: 20px; } + /* ── Meta Bar ── */ .note-meta-bar { - margin-top: 12px; font-size: 12px; color: #666; display: flex; gap: 12px; padding: 8px 0; + margin-top: 12px; font-size: 12px; color: var(--rs-text-muted); + display: flex; gap: 12px; padding: 8px 0; align-items: center; } + .meta-live { color: var(--rs-success); font-weight: 500; } + .meta-demo { color: var(--rs-warning); font-weight: 500; } /* ── Editor Toolbar ── */ .editor-toolbar { display: flex; flex-wrap: wrap; gap: 2px; align-items: center; - background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; - padding: 4px 6px; margin-bottom: 2px; + background: var(--rs-toolbar-bg); border: 1px solid var(--rs-toolbar-panel-border); + border-radius: 8px; padding: 4px 6px; margin-bottom: 2px; } .toolbar-group { display: flex; gap: 1px; } - .toolbar-sep { width: 1px; height: 20px; background: #1e293b; margin: 0 4px; } + .toolbar-sep { width: 1px; height: 20px; background: var(--rs-toolbar-sep); margin: 0 4px; } .toolbar-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 28px; border: none; border-radius: 4px; - background: transparent; color: #94a3b8; cursor: pointer; + background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; font-family: inherit; transition: all 0.15s; } - .toolbar-btn:hover { background: #1e293b; color: #e2e8f0; } - .toolbar-btn.active { background: #312e81; color: #a5b4fc; } + .toolbar-btn svg { width: 16px; height: 16px; flex-shrink: 0; } + .toolbar-btn:hover { background: var(--rs-toolbar-btn-hover); color: var(--rs-toolbar-btn-text); } + .toolbar-btn.active { background: var(--rs-primary); color: #fff; } .toolbar-select { - padding: 2px 4px; border-radius: 4px; border: 1px solid #1e293b; - background: #0f172a; color: #94a3b8; font-size: 12px; cursor: pointer; - font-family: inherit; + padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border); + background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer; + font-family: inherit; transition: border-color 0.15s; } - .toolbar-select:focus { outline: none; border-color: #4f46e5; } + .toolbar-select:focus { outline: none; border-color: var(--rs-primary); } /* ── Tiptap Editor ── */ .editor-wrapper { - background: #1e1e2e; border: 1px solid #333; border-radius: 10px; - overflow: hidden; - } - .editor-wrapper .editable-title { - padding: 16px 20px 0; - } - .editor-wrapper .editor-toolbar { - margin: 4px 8px; border-radius: 6px; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); + border-radius: 10px; overflow: hidden; } + .editor-wrapper .editable-title { padding: 16px 20px 0; } + .editor-wrapper .editor-toolbar { margin: 4px 8px; border-radius: 6px; } .tiptap-container .tiptap { - min-height: 300px; padding: 16px 20px; outline: none; - font-size: 15px; line-height: 1.7; color: #e0e0e0; + min-height: 300px; padding: 20px 24px; outline: none; + font-size: 15px; line-height: 1.75; color: var(--rs-text-primary); } .tiptap-container .tiptap:focus { outline: none; } - /* Prose styles */ - .tiptap-container .tiptap h1 { font-size: 1.8em; font-weight: 700; margin: 1em 0 0.4em; color: #f1f5f9; } - .tiptap-container .tiptap h2 { font-size: 1.4em; font-weight: 600; margin: 0.8em 0 0.3em; color: #e2e8f0; } - .tiptap-container .tiptap h3 { font-size: 1.15em; font-weight: 600; margin: 0.7em 0 0.25em; color: #cbd5e1; } - .tiptap-container .tiptap h4 { font-size: 1em; font-weight: 600; margin: 0.6em 0 0.2em; color: #94a3b8; } - .tiptap-container .tiptap p { margin: 0.4em 0; } + /* ── Prose Styles ── */ + .tiptap-container .tiptap h1 { + font-size: 1.75em; font-weight: 700; margin: 1.2em 0 0.5em; color: var(--rs-text-primary); + padding-bottom: 0.3em; border-bottom: 1px solid var(--rs-border-subtle); + } + .tiptap-container .tiptap h2 { font-size: 1.35em; font-weight: 600; margin: 1em 0 0.4em; color: var(--rs-text-primary); } + .tiptap-container .tiptap h3 { font-size: 1.1em; font-weight: 600; margin: 0.8em 0 0.3em; color: var(--rs-text-secondary); } + .tiptap-container .tiptap h4 { + font-size: 0.95em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; + margin: 0.7em 0 0.25em; color: var(--rs-text-muted); + } + .tiptap-container .tiptap p { margin: 0.5em 0; } .tiptap-container .tiptap blockquote { - border-left: 3px solid #4f46e5; padding-left: 16px; margin: 0.8em 0; - color: #94a3b8; font-style: italic; + border-left: 3px solid var(--rs-primary); padding: 4px 0 4px 16px; margin: 0.8em 0; + color: var(--rs-text-secondary); font-style: italic; + background: var(--rs-bg-surface-raised); border-radius: 0 6px 6px 0; } .tiptap-container .tiptap code { - background: #2a2a3e; padding: 2px 6px; border-radius: 4px; - font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9em; color: #a5b4fc; + background: var(--rs-bg-surface-raised); padding: 2px 6px; border-radius: 4px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.88em; color: var(--rs-accent); } .tiptap-container .tiptap pre { - background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; - padding: 12px 16px; margin: 0.8em 0; overflow-x: auto; + background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-subtle); + border-radius: 8px; padding: 14px 16px; margin: 1em 0; overflow-x: auto; } .tiptap-container .tiptap pre code { - background: none; padding: 0; border-radius: 0; color: #e0e0e0; - font-size: 13px; line-height: 1.5; + background: none; padding: 0; border-radius: 0; color: var(--rs-text-primary); + font-size: 13px; line-height: 1.6; } - .tiptap-container .tiptap ul, .tiptap-container .tiptap ol { - padding-left: 24px; margin: 0.4em 0; - } - .tiptap-container .tiptap li { margin: 0.15em 0; } - .tiptap-container .tiptap li p { margin: 0.1em 0; } + .tiptap-container .tiptap ul, .tiptap-container .tiptap ol { padding-left: 24px; margin: 0.5em 0; } + .tiptap-container .tiptap li { margin: 0.2em 0; } + .tiptap-container .tiptap li p { margin: 0.15em 0; } + .tiptap-container .tiptap li::marker { color: var(--rs-text-muted); } /* Task list */ - .tiptap-container .tiptap ul[data-type="taskList"] { - list-style: none; padding-left: 4px; - } - .tiptap-container .tiptap ul[data-type="taskList"] li { - display: flex; align-items: flex-start; gap: 8px; - } - .tiptap-container .tiptap ul[data-type="taskList"] li label { - margin-top: 3px; - } + .tiptap-container .tiptap ul[data-type="taskList"] { list-style: none; padding-left: 4px; } + .tiptap-container .tiptap ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; } + .tiptap-container .tiptap ul[data-type="taskList"] li label { margin-top: 3px; } .tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p { - text-decoration: line-through; color: #666; + text-decoration: line-through; color: var(--rs-text-muted); + } + .tiptap-container .tiptap ul[data-type="taskList"] li label input[type="checkbox"] { + accent-color: var(--rs-primary); width: 15px; height: 15px; } .tiptap-container .tiptap img { - max-width: 100%; border-radius: 8px; margin: 0.5em 0; + max-width: 100%; border-radius: 8px; margin: 0.75em 0; + border: 1px solid var(--rs-border-subtle); } .tiptap-container .tiptap a { - color: #818cf8; text-decoration: underline; text-underline-offset: 2px; + color: var(--rs-primary-hover); text-decoration: underline; + text-underline-offset: 2px; text-decoration-color: rgba(99, 102, 241, 0.4); } - .tiptap-container .tiptap a:hover { color: #a5b4fc; } - .tiptap-container .tiptap hr { - border: none; border-top: 1px solid #333; margin: 1.5em 0; - } - .tiptap-container .tiptap strong { color: #f1f5f9; } + .tiptap-container .tiptap a:hover { text-decoration-color: var(--rs-primary-hover); } + .tiptap-container .tiptap hr { border: none; border-top: 1px solid var(--rs-border); margin: 1.5em 0; } + .tiptap-container .tiptap strong { color: var(--rs-text-primary); font-weight: 600; } .tiptap-container .tiptap em { color: inherit; } - .tiptap-container .tiptap s { color: #666; } + .tiptap-container .tiptap s { color: var(--rs-text-muted); } .tiptap-container .tiptap u { text-underline-offset: 3px; } /* Placeholder */ .tiptap-container .tiptap p.is-editor-empty:first-child::before { content: attr(data-placeholder); - float: left; color: #555; pointer-events: none; height: 0; + float: left; color: var(--rs-text-muted); pointer-events: none; height: 0; + font-style: italic; } + /* ── URL Popover ── */ + .url-popover { + position: absolute; z-index: 110; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border); + border-radius: 10px; box-shadow: var(--rs-shadow-md); + padding: 8px; min-width: 300px; + animation: popover-in 0.15s ease-out; + } + @keyframes popover-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } + } + .url-popover__input { + width: 100%; padding: 8px 10px; border-radius: 6px; + border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); + color: var(--rs-input-text); font-size: 13px; font-family: inherit; + outline: none; margin-bottom: 6px; transition: border-color 0.15s; + } + .url-popover__input:focus { border-color: var(--rs-primary); } + .url-popover__actions { display: flex; gap: 6px; justify-content: flex-end; } + .url-popover__btn { + padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600; + cursor: pointer; border: none; transition: all 0.15s; + } + .url-popover__btn--insert { background: var(--rs-primary); color: #fff; } + .url-popover__btn--insert:hover { background: var(--rs-primary-hover); } + .url-popover__btn--cancel { + background: transparent; color: var(--rs-text-secondary); + border: 1px solid var(--rs-border); + } + .url-popover__btn--cancel:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); } + /* ── Slash Menu ── */ .slash-menu { position: absolute; z-index: 100; - background: #1e1e2e; border: 1px solid #333; border-radius: 8px; - box-shadow: 0 8px 24px rgba(0,0,0,0.4); - max-height: 320px; overflow-y: auto; min-width: 220px; + background: var(--rs-bg-surface); border: 1px solid var(--rs-border); + border-radius: 10px; box-shadow: var(--rs-shadow-lg); + max-height: 360px; overflow-y: auto; min-width: 240px; display: none; } + .slash-menu__header { + padding: 8px 12px 6px; font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; + color: var(--rs-text-muted); border-bottom: 1px solid var(--rs-border-subtle); + } .slash-menu-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; transition: background 0.1s; } - .slash-menu-item:hover, .slash-menu-item.selected { - background: #312e81; - } + .slash-menu-item:last-child { border-radius: 0 0 10px 10px; } + .slash-menu-item:hover, .slash-menu-item.selected { background: var(--rs-bg-hover); } .slash-menu-icon { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; - background: #2a2a3e; border-radius: 4px; font-size: 13px; font-weight: 600; color: #a5b4fc; + background: var(--rs-bg-surface-raised); border-radius: 6px; + font-size: 13px; font-weight: 600; color: var(--rs-primary); flex-shrink: 0; } + .slash-menu-icon svg { width: 16px; height: 16px; } .slash-menu-text { flex: 1; } - .slash-menu-title { font-size: 13px; font-weight: 500; color: #e2e8f0; } - .slash-menu-desc { font-size: 11px; color: #666; } + .slash-menu-title { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); } + .slash-menu-desc { font-size: 11px; color: var(--rs-text-muted); } + .slash-menu-hint { + font-size: 10px; color: var(--rs-text-muted); padding: 1px 6px; + background: var(--rs-bg-surface-raised); border-radius: 3px; margin-left: auto; + } /* ── Code highlighting (lowlight) ── */ .tiptap-container .tiptap .hljs-keyword { color: #c792ea; } diff --git a/server/spaces.ts b/server/spaces.ts index 6652ec2..7c45c61 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -103,7 +103,7 @@ export type CreateSpaceResult = * All creation endpoints should call this instead of duplicating logic. */ export async function createSpace(opts: CreateSpaceOpts): Promise { - const { name, slug, ownerDID, visibility = 'public_read', enabledModules, source = 'api' } = opts; + const { name, slug, ownerDID, visibility = 'public', enabledModules, source = 'api' } = opts; if (!name || !slug) return { ok: false, error: "Name and slug are required", status: 400 }; if (!/^[a-z0-9-]+$/.test(slug)) return { ok: false, error: "Slug must contain only lowercase letters, numbers, and hyphens", status: 400 }; @@ -197,18 +197,18 @@ spaces.get("/", async (c) => { await loadCommunity(slug); const data = getDocumentData(slug); if (data?.meta) { - const vis = data.meta.visibility || "public_read"; + const vis = data.meta.visibility || "public"; const isOwner = !!(claims && data.meta.ownerDID === claims.sub); const memberEntry = claims ? data.members?.[claims.sub] : undefined; const isMember = !!memberEntry; // Determine accessibility - const isPublic = vis === "public" || vis === "public_read"; - const isAuthenticated = vis === "authenticated"; - const accessible = isPublic || isOwner || isMember || (isAuthenticated && !!claims); + const isPublicSpace = vis === "public"; + const isPermissioned = vis === "permissioned"; + const accessible = isPublicSpace || isOwner || isMember || (isPermissioned && !!claims); // For unauthenticated: only show public spaces - if (!claims && !isPublic) continue; + if (!claims && !isPublicSpace) continue; // Determine relationship const relationship = isOwner @@ -271,9 +271,9 @@ spaces.post("/", async (c) => { enabledModules?: string[]; }>(); - const { name, slug, visibility = "public_read", enabledModules } = body; + const { name, slug, visibility = "public", enabledModules } = body; - const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"]; + const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"]; if (visibility && !validVisibilities.includes(visibility)) { return c.json({ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, 400); } @@ -440,7 +440,7 @@ spaces.get("/admin", async (c) => { spacesList.push({ slug: data.meta.slug, name: data.meta.name, - visibility: data.meta.visibility || "public_read", + visibility: data.meta.visibility || "public", createdAt: data.meta.createdAt, ownerDID: data.meta.ownerDID, shapeCount, @@ -622,7 +622,7 @@ spaces.patch("/:slug", async (c) => { const body = await c.req.json<{ name?: string; visibility?: SpaceVisibility; description?: string }>(); if (body.visibility) { - const valid: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"]; + const valid: SpaceVisibility[] = ["public", "permissioned", "private"]; if (!valid.includes(body.visibility)) { return c.json({ error: `Invalid visibility. Must be one of: ${valid.join(", ")}` }, 400); } diff --git a/website/canvas.html b/website/canvas.html index 0165df2..6ce3f01 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1136,6 +1136,7 @@ folk-choice-vote, folk-choice-rank, folk-choice-spider, + folk-choice-conviction, folk-social-post, folk-splat, folk-blender, @@ -1153,7 +1154,7 @@ folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, - folk-choice-vote, folk-choice-rank, folk-choice-spider, + folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction, folk-social-post, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad, folk-zine-gen, folk-rapp) { cursor: crosshair; @@ -1165,7 +1166,7 @@ folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, - folk-choice-vote, folk-choice-rank, folk-choice-spider, + folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction, folk-social-post, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad, folk-zine-gen, folk-rapp):hover { outline: 2px dashed #3b82f6;