/** * Cozy Poll * * Collaborative voting with three colors: * 🟒 green (love it) 🟑 yellow (OK) πŸ”΄ red (veto) * * Winner: fewest reds, then most greens. * * Identity follows the scrabble idiom: * - `users` is a per-space directory of joined participants. * - Each viewer's `myName` is per-user; it is set once on join and treated as * immutable thereafter. The join name/avatar come from the viewer's shared * profile (`wish({ query: "#profile" })` β€” its built-in UI covers profile * create/pick); programmatic callers can still pass an explicit name in the * `joinAs` event. * - The first joiner's name is captured into `adminName` (per-space). They can * add/remove options and reset votes. `isAdmin` is derived, not stored. * - Open host takeover: any joined participant can `claimHost`, transferring * the role (and the host controls) to themselves. Deliberately ungated * beyond "must be joined"; see `ADMIN-FUTURE.md`. */ import { computed, Default, handler, NAME, nonPrivateRandom, pattern, type PerSpace, type PerUser, safeDateNow, Stream, UI, type VNode, wish, Writable, } from "commonfabric"; export interface User { name: string; /** Avatar URL or glyph, snapshotted from the joiner's shared profile. */ avatar?: string; color: string; joinedAt: number; } export interface Option { id: string; title: string; addedByName: string; } export type VoteColor = "green" | "yellow" | "red"; export interface Vote { voterName: string; optionId: string; voteType: VoteColor; } export interface JoinEvent { name?: string; } export type ClaimHostEvent = Record; export interface AddOptionEvent { title?: string; } export interface RemoveOptionEvent { optionId: string; } export interface CastVoteEvent { optionId: string; voteType: VoteColor; } export type ResetVotesEvent = Record; type QuestionCell = Writable>; type OptionsCell = Writable>; type VotesCell = Writable>; type UsersCell = Writable>; type NameCell = Writable>; const PLAYER_COLORS = [ "#2f8a64", "#c2573a", "#3b4a6b", "#a33b35", "#b27722", "#7c3aed", ]; const POLL_THEME = { fontFamily: "'Avenir Next', 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif", borderRadius: "8px", density: "comfortable" as const, colorScheme: "light" as const, colors: { primary: "#2f6f4e", primaryForeground: "#ffffff", secondary: "#3b4a6b", secondaryForeground: "#ffffff", background: "#f1f5ef", surface: "#ffffff", surfaceHover: "#f6faf4", text: "#1d2a1f", textMuted: "#5d6f63", border: "#cbd9cf", borderMuted: "#e2ebe5", accent: "#c2573a", accentForeground: "#ffffff", success: "#2f8a64", successForeground: "#ffffff", error: "#a33b35", errorForeground: "#ffffff", warning: "#b27722", warningForeground: "#ffffff", }, }; const VOTE_SWATCH: Record = { green: "#2f8a64", yellow: "#d4a82f", red: "#a33b35", }; const trimmedName = (n: string | undefined) => (n ?? "").trim(); const newOptionId = () => `o_${safeDateNow().toString(36)}_${ Math.floor(nonPrivateRandom() * 1e6).toString(36) }`; const colorForIndex = (i: number) => PLAYER_COLORS[i % PLAYER_COLORS.length]; const getInitials = (name: string): string => { const trimmed = name.trim(); if (!trimmed) return "?"; return trimmed.split(/\s+/).map((w) => w[0]).join("").toUpperCase().slice( 0, 2, ); }; // `profileName`/`profileAvatar` arrive as plain strings resolved from the // viewer's shared profile (named `computed` values auto-unwrap as handler // state). An explicit `name` in the event (tests, headless drivers) overrides // the profile name β€” and then deliberately skips the profile avatar. const joinAs = handler(({ name }, { users, myName, adminName, profileName, profileAvatar }) => { const override = trimmedName(name); const trimmed = override || trimmedName(profileName); if (!trimmed) return; const current = trimmedName(myName.get()); if (current) return; const existing = users.get(); if (existing.some((u) => u.name === trimmed)) return; const user: User = { name: trimmed, avatar: override ? "" : (profileAvatar ?? "").trim(), color: colorForIndex(existing.length), joinedAt: safeDateNow(), }; users.push(user); myName.set(trimmed); if (trimmedName(adminName.get()) === "") { adminName.set(trimmed); } }); // Open host takeover: any joined participant can claim the host role, which // transfers it away from the current host (isAdmin is derived from this). This // is deliberately ungated beyond "must be joined" β€” see ADMIN-FUTURE.md for the // kernel-level authority model this pattern-level check is a placeholder for. const claimHost = handler((_, { myName, adminName }) => { const me = trimmedName(myName.get()); if (!me) return; if (trimmedName(adminName.get()) === me) return; adminName.set(me); }); const addOption = handler(({ title }, { options, myName, adminName, optionDraft }) => { const me = trimmedName(myName.get()); const admin = trimmedName(adminName.get()); if (!me || me !== admin) return; const trimmed = trimmedName(title ?? optionDraft.get()); if (!trimmed) return; options.push({ id: newOptionId(), title: trimmed, addedByName: me, }); optionDraft.set(""); }); const removeOption = handler(({ optionId }, { options, votes, myName, adminName }) => { const me = trimmedName(myName.get()); const admin = trimmedName(adminName.get()); if (!me || me !== admin) return; const current = options.get(); const target = current.find((o) => o.id === optionId); if (!target) return; options.remove(target); votes.set(votes.get().filter((v) => v.optionId !== optionId)); }); const castVote = handler(({ optionId, voteType }, { votes, myName }) => { const me = trimmedName(myName.get()); if (!me) return; const current = votes.get(); const existingIdx = current.findIndex( (v) => v.voterName === me && v.optionId === optionId, ); if (existingIdx >= 0) { const existing = current[existingIdx]; if (existing.voteType === voteType) { votes.remove(existing); return; } votes.key(existingIdx).key("voteType").set(voteType); return; } votes.push({ voterName: me, optionId, voteType }); }); const resetVotes = handler((_, { votes, myName, adminName }) => { const me = trimmedName(myName.get()); const admin = trimmedName(adminName.get()); if (!me || me !== admin) return; votes.set([]); }); export interface ClearVoteEvent { optionId: string; } const clearMyVote = handler(({ optionId }, { votes, myName }) => { const me = trimmedName(myName.get()); if (!me) return; votes.set( votes.get().filter( (v) => !(v.voterName === me && v.optionId === optionId), ), ); }); interface OptionTally { option: Option; green: number; yellow: number; red: number; voters: Array<{ name: string; voteType: VoteColor; color: string }>; } const tallyOptions = ( options: readonly Option[], votes: readonly Vote[], users: readonly User[], ): OptionTally[] => { const colorByName = new Map(users.map((u) => [u.name, u.color])); const tallies = options.map((option): OptionTally => { const optionVotes = votes.filter((v) => v.optionId === option.id); return { option, green: optionVotes.filter((v) => v.voteType === "green").length, yellow: optionVotes.filter((v) => v.voteType === "yellow").length, red: optionVotes.filter((v) => v.voteType === "red").length, voters: optionVotes.map((v) => ({ name: v.voterName, voteType: v.voteType, color: colorByName.get(v.voterName) ?? "#888", })), }; }); return [...tallies].sort((a, b) => { if (a.red !== b.red) return a.red - b.red; return b.green - a.green; }); }; const myVoteFor = ( votes: readonly Vote[], me: string, optionId: string, ): VoteColor | undefined => { if (!me) return undefined; return votes.find( (v) => v.voterName === me && v.optionId === optionId, )?.voteType; }; export interface CozyPollInput { question?: PerSpace>; options?: PerSpace>; votes?: PerSpace>; users?: PerSpace>; adminName?: PerSpace>; myName?: PerUser>; // optionDraft etc. are internal form drafts, declared as local // per-session cells in the pattern body (parking-coordinator idiom). } export interface CozyPollOutput { [NAME]: string; [UI]: VNode; question: string; options: readonly Option[]; votes: readonly Vote[]; users: readonly User[]; adminName: string; myName: string; userCount: number; optionCount: number; voteCount: number; isJoined: boolean; isAdmin: boolean; joinAs: Stream; claimHost: Stream; addOption: Stream; removeOption: Stream; castVote: Stream; clearMyVote: Stream; resetVotes: Stream; } // Stable empty fallbacks for the output snapshots below β€” fresh `[]` per // recompute would make the computed results non-idempotent. const EMPTY_OPTIONS: Option[] = []; const EMPTY_VOTES: Vote[] = []; const EMPTY_USERS: User[] = []; export default pattern( ( { question, options, votes, users, adminName, myName, }, ) => { // Internal per-session form drafts β€” local to each browser session, // not exposed as pattern inputs. Uses the scoped-constructor idiom // introduced by parking-coordinator (PR #3610). const optionDraft = Writable.perSession.of(""); // Two-step confirmation for destructive actions. Stores the optionId // pending remove-confirm (null = nothing pending). Same idiom as // parking-coordinator's `removePersonConfirmTarget`. const removeConfirmTarget = Writable.perSession.of(null); const resetConfirmPending = Writable.perSession.of(false); // Click-to-reveal for the host-takeover control, so it stays out of the // way until a non-host clicks the "Hosted by …" label. const claimHostRevealed = Writable.perSession.of(false); // Resolve THIS viewer's shared profile. The `#profile` wish's built-in UI // covers the whole lifecycle: a create surface when the viewer has no // profile, a link when they have one, and a picker (with inline create) // when they have several. The field targets give the snapshot strings. const profileWish = wish<{ name?: string; avatar?: string }>({ query: "#profile", }); const profileNameWish = wish({ query: "#profileName" }); const profileAvatarWish = wish({ query: "#profileAvatar" }); const profileName = computed(() => profileNameWish.result ?? ""); const profileAvatar = computed(() => profileAvatarWish.result ?? ""); const hasProfile = computed(() => (profileNameWish.result ?? "").trim() !== "" ); const joinLabel = computed(() => hasProfile ? `Join as ${profileName}` : "Create a profile to join" ); const boundJoin = joinAs({ users, myName, adminName, profileName, profileAvatar, }); const boundClaimHost = claimHost({ myName, adminName }); const boundAddOption = addOption({ options, myName, adminName, optionDraft, }); const boundRemoveOption = removeOption({ options, votes, myName, adminName, }); const boundCastVote = castVote({ votes, myName }); const boundClearMyVote = clearMyVote({ votes, myName }); const boundResetVotes = resetVotes({ votes, myName, adminName }); const userCount = users.length; const optionCount = options.length; const voteCount = votes.length; // Resolve the viewer's name ONCE here at the top level. PerUser `myName` // resolves in this scope, but NOT inside the per-option `options.map(...)` // lift β€” there `trimmedName(myName)` was handed an unresolved ref and threw // `(n ?? "").trim is not a function`, silently nulling out each option's // `myVote` (so nothing dimmed). Passing this resolved value down avoids it. const me = trimmedName(myName); const isJoined = trimmedName(myName) !== ""; const isAdmin = trimmedName(myName) !== "" && trimmedName(myName) === trimmedName(adminName); const joinHint = trimmedName(adminName) === "" ? "First to join becomes the host." : `Hosted by ${trimmedName(adminName)}.`; // Hoist a boolean cell for the reset-confirm JSX ternary so TS doesn't // narrow `resetConfirmPending` itself and lose the `.set` method in // the false branch. const isResetConfirm = computed(() => resetConfirmPending.get()); const isClaimHostRevealed = computed(() => claimHostRevealed.get()); const ranked = tallyOptions(options, votes, users); const topChoice = voteCount > 0 && ranked.length > 0 ? ranked[0] : null; // A joined viewer who is not the current host can take the host role. const canClaimHost = trimmedName(myName) !== "" && trimmedName(myName) !== trimmedName(adminName); return { [NAME]: "Cozy poll", [UI]: ( {/* Header */}

{question}

{computed(() => { const u = userCount ?? 0; const o = optionCount ?? 0; const v = voteCount ?? 0; const admin = trimmedName(adminName); const me = trimmedName(myName); const amAdmin = me !== "" && me === admin; // "you are the host" is handled by the HOST chip in the // top right; only call out the host's name to non-admins. const hostNote = !amAdmin && me !== "" && admin !== "" ? ` Β· hosted by ${admin}` : ""; return (
{u} joined Β· {o} options Β· {v} votes{hostNote}
); })}
{computed(() => { const me = trimmedName(myName); if (me === "") return null; const admin = trimmedName(adminName); const amAdmin = me !== "" && me === admin; return (
{amAdmin ? ( HOST ) : null} ● {me}
); })}
{/* Join card β€” hidden after the viewer joins. */} {isJoined ? null : (
Join the poll
{joinHint}
{ /* Built-in profile UI: create a profile when there is none, pick between existing profiles otherwise. */ }
{profileWish[UI]}
!hasProfile)} > {joinLabel}
)} { /* Open host takeover β€” kept out of the way: a non-host sees a subtle "Hosted by …" label and clicks it to reveal the "Become host" button. Plain JSX with a per-session toggle so the onClicks lower as handlers (not lifts). */ } {canClaimHost ? (isClaimHostRevealed ? (
{joinHint} { boundClaimHost.send({}); claimHostRevealed.set(false); }} > Become host claimHostRevealed.set(false)} > Cancel
) : (
)) : null} {/* Top choice β€” only when there are votes */} {computed(() => { const tally = topChoice; if (!tally) return null; const parts: string[] = []; if (tally.green > 0) parts.push(`${tally.green} love it`); if (tally.yellow > 0) { parts.push(`${tally.yellow} okay with it`); } if (tally.red > 0) parts.push(`${tally.red} can't accept`); const summary = parts.join(", "); const hasReds = tally.red > 0; return (
πŸ† Top choice
{tally.option.title}
{summary}
); })} {/* All options summary β€” only when there are options */} {computed(() => { const list = ranked; if (!list || list.length === 0) return null; const me = trimmedName(myName); return (
All options
{list.map((tally) => (
{tally.option.title}
{tally.voters.map((v) => ( {getInitials(v.name)} ))}
))}
); })} {/* Empty state */} {computed(() => { if (options && options.length > 0) return null; const me = trimmedName(myName); const admin = trimmedName(adminName); const amAdmin = me !== "" && me === admin; const hint = amAdmin ? "Add the first one above." : admin !== "" ? `${admin} can add the first option.` : "Waiting for a host to join."; return (
No options yet
{hint}
); })} {/* Interactive options β€” vote per option */} {options.map((option) => { const oid = option.id; const optionTitle = option.title; // Use the top-level-resolved `me`, not `trimmedName(myName)`: // the raw PerUser ref doesn't resolve inside this per-option // lift (see `me` above). const myVote = myVoteFor(votes, me, oid); const rank = computed(() => { const idx = ranked.findIndex( (t) => t.option.id === oid, ); return idx >= 0 ? idx + 1 : 0; }); const isRemoveConfirm = removeConfirmTarget.get() === oid; // The castVote handler toggles per-color: clicking your // active color clears, a different color updates, none // pushes. JSX dispatches one event per click; the handler // decides what to do. The onClick lambdas are inlined // (not assigned to locals) so the transformer lifts each // into a handler-with-bindings β€” same idiom as // parking-coordinator's per-item action dispatch. return (
#{rank}
{optionTitle}
added by {option.addedByName} { /* Admin-only Remove β€” muted, far from the vote chips. Two-step confirm when the option has votes (same idiom as parking-coordinator). */ } {isAdmin ? ( ) : null}
{isRemoveConfirm ? (
Remove "{optionTitle}" and discard its votes? { boundRemoveOption.send({ optionId: oid }); removeConfirmTarget.set(null); }} > Yes, remove removeConfirmTarget.set(null)} > Cancel
) : null}
{isJoined ? (
boundCastVote.send({ optionId: oid, voteType: "green", })} > 🟒 boundCastVote.send({ optionId: oid, voteType: "yellow", })} > 🟑 boundCastVote.send({ optionId: oid, voteType: "red", })} > πŸ”΄
) : null}
); })} {/* Host controls β€” only the admin sees this card. */} {isAdmin ? (
Host controls
Add {isResetConfirm ? ( <> { boundResetVotes.send({}); resetConfirmPending.set(false); }} > Yes, reset resetConfirmPending.set(false)} > Cancel ) : ( resetConfirmPending.set(true)} > Reset votes )}
) : null}
), question, // Output snapshots readable from OTHER runtimes (multi-user tests, // remote viewers): raw scoped values read as undefined in runtimes that // didn't write them, and a computed that RETURNS undefined is // indistinguishable from "not yet computed" for cross-runtime readers β€” // so every snapshot yields a real, stable value (the shared EMPTY // constants keep the fallback idempotent across recomputes). options: computed(() => options ?? EMPTY_OPTIONS), votes: computed(() => votes ?? EMPTY_VOTES), users: computed(() => users ?? EMPTY_USERS), adminName: computed(() => trimmedName(adminName)), myName: computed(() => trimmedName(myName)), userCount, optionCount, voteCount, isJoined, isAdmin, joinAs: boundJoin, claimHost: boundClaimHost, addOption: boundAddOption, removeOption: boundRemoveOption, castVote: boundCastVote, clearMyVote: boundClearMyVote, resetVotes: boundResetVotes, }; }, );