diff --git a/docs/source/_static/css/is-this-ai.css b/docs/source/_static/css/is-this-ai.css new file mode 100644 index 0000000..53d21a2 --- /dev/null +++ b/docs/source/_static/css/is-this-ai.css @@ -0,0 +1,540 @@ +/* +Peter, Is this AI? widget styles +================================== + +Author: Akshay Mestry +Created on: 12 August, 2026 +Last updated on: 17 August, 2026 +*/ + +.site-is-this-ai { + max-width: var(--km-layout-container, 861px); + margin: 2rem auto 0; + font-family: var(--km-font-sans, inherit); + color: hsl(var(--km-color-fg)); +} + +.site-is-this-ai--hidden { + display: none !important; +} + +.site-is-this-ai__card { + transition-property: opacity, transform; + transition-timing-function: var(--km-ease-smooth, ease); + transition-duration: var(--km-duration-normal, 200ms); +} + +.site-is-this-ai__card--choice { + background: transparent; + border: 0; + box-shadow: none; + padding: 0; +} + +.site-is-this-ai .sd-tab-set { + justify-content: start; +} + +.site-is-this-ai__title { + font-size: 1.15rem; + font-weight: 600; + margin: 0 0 1rem 0; + color: hsl(var(--km-color-fg-strong)); +} + +.site-is-this-ai__subtitle { + font-size: 0.9rem; + color: hsl(var(--km-color-fg) / 0.7); + margin: -0.5rem 0 1rem 0; +} + +.site-is-this-ai__tab-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; + align-items: stretch; +} + +.site-is-this-ai__tab-copy { + display: flex; + flex-direction: column; + min-height: 100%; + padding: 0.5rem 0.25rem; +} + +.site-is-this-ai__tab-title { + font-size: 2rem; + line-height: 1.1; + letter-spacing: -0.04em; + font-weight: 700; + margin: 0; + color: hsl(var(--km-color-fg-strong)); +} + +.site-is-this-ai__tab-copy .subtitle-text { + margin-bottom: 0; +} + +.site-is-this-ai__tab-form { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 100%; + padding: 0.5rem 0.25rem; +} + +@media (max-width: 900px) { + .site-is-this-ai__tab-layout { + grid-template-columns: 1fr; + } +} + +.site-is-this-ai__field { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; +} + +.site-is-this-ai__field--stacked { + gap: 1rem; +} + +.site-is-this-ai__input::placeholder { + text-transform: inherit; +} + +.site-is-this-ai__input { + border-radius: var(--km-radius-round, 0.75rem) !important; +} + +.site-is-this-ai__input, +.site-is-this-ai__textarea { + background-color: hsl(var(--km-color-input, var(--km-color-bg))); + border: 1px solid hsl(var(--km-color-border)); + border-radius: var(--km-radius-normal, 0.75rem); + padding: 0.6rem 0.75rem; + color: hsl(var(--km-color-fg)); + font-family: inherit; + font-size: 0.95rem; +} + +.site-is-this-ai__input:focus-visible, +.site-is-this-ai__textarea:focus-visible { + outline: 2px solid hsl(var(--km-color-accent-bg)); +} + +.site-is-this-ai__textarea { + min-height: 140px; + resize: vertical; + border-radius: 1.5rem; + padding-inline: 1rem; +} + +.site-is-this-ai__composer { + position: relative; +} + +.site-is-this-ai__textarea--composer { + width: 100%; + max-height: 400px; + padding-top: 1rem; + padding-bottom: 3.5rem; + resize: none; + overflow-y: auto; +} + +.site-is-this-ai__composer-status { + position: absolute; + left: 1rem; + bottom: 1.15rem; + margin: 0; + font-size: 0.8rem; + color: hsl(var(--km-color-fg) / 0.6); + pointer-events: none; +} + +.site-is-this-ai__composer-nav { + position: absolute; + right: calc(0.5rem + 2.25rem + 0.5rem); + bottom: 1rem; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.site-is-this-ai__composer-nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + border: none; + background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); + color: hsl(var(--km-color-fg)); + font-size: 0.75rem; + cursor: pointer; + transition-property: opacity, transform; + transition-timing-function: var(--km-ease-smooth, ease); + transition-duration: var(--km-duration-fast, 120ms); +} + +.site-is-this-ai__composer-nav-btn:hover { + opacity: 0.8; +} + +.site-is-this-ai__composer-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.site-is-this-ai__composer-nav-label { + font-size: 0.75rem; + color: hsl(var(--km-color-fg) / 0.6); + white-space: nowrap; +} + +.site-is-this-ai__composer-submit { + position: absolute; + right: 0.5rem; + bottom: 1rem; + display: flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + border: none; + background-color: hsl(var(--km-color-primary)); + color: hsl(var(--km-color-bg)); + font-size: 0.9rem; + cursor: pointer; + transition-property: opacity, transform; + transition-timing-function: var(--km-ease-smooth, ease); + transition-duration: var(--km-duration-fast, 120ms); +} + +.site-is-this-ai__composer-submit:hover { + opacity: 0.9; +} + +.site-is-this-ai__composer-submit:disabled, +.site-is-this-ai__button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.site-is-this-ai__button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border-radius: var(--km-radius-round, 500px); + padding: 0.55rem 1.25rem; + font-size: 0.9rem; + cursor: pointer; + transition-property: opacity, transform; + transition-timing-function: var(--km-ease-smooth, ease); + transition-duration: var(--km-duration-fast, 120ms); + background-color: hsl(var(--km-color-fg)); + color: hsl(var(--km-color-bg)); + border: 1px solid hsl(var(--km-color-border)); +} + +.site-is-this-ai__button--secondary { + background-color: hsl(var(--km-color-code-bg)); + color: hsl(var(--km-color-fg)); + border: 1px solid hsl(var(--km-color-border)); +} + +.site-is-this-ai__button:hover { + background-color: hsl(var(--km-color-code-bg)); + color: hsl(var(--km-color-fg)); + border: 1px solid hsl(var(--km-color-border)); +} + +.site-is-this-ai__button--secondary:hover { + background-color: hsl(var(--km-color-fg)); + color: hsl(var(--km-color-bg)); + border: 1px solid hsl(var(--km-color-border)); +} + +.site-is-this-ai__action-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.site-is-this-ai__error--inline { + margin-top: 0; +} + +.site-is-this-ai__error { + color: hsl(var(--km-color-red, 0 70% 55%)); + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.site-is-this-ai__submission-status { + color: hsl(var(--km-color-green, 140 55% 45%)); + font-size: 0.85rem; + margin-top: 0 !important; +} + +.site-is-this-ai__error:empty, +.site-is-this-ai__submission-status:empty { + display: none; + margin-top: 0; +} + +.site-is-this-ai__lobby-count { + font-size: 0.85rem; + color: hsl(var(--km-color-fg) / 0.7); + margin-bottom: 1.75rem; +} + +.site-is-this-ai__avatar-stack { + list-style: none; + margin: 0 !important; + padding: 0; + display: flex; + flex-direction: row; + align-items: center; +} + +.site-is-this-ai__avatar-stack--host { + margin-bottom: 2.25rem !important; +} + +.site-is-this-ai__avatar { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 3.5rem; + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + color: #fff; + font-size: 1.15rem; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: uppercase; + margin-left: -0.8rem !important; + margin-top: 0 !important; + position: relative; + box-shadow: 0 0 0 2px hsl(var(--km-color-bg)); + transition: transform var(--km-duration-fast, 120ms) var(--km-ease-smooth, ease); +} + +.site-is-this-ai__avatar:first-child { + margin-left: 0 !important; +} + +.site-is-this-ai__avatar:hover { + transform: translateY(-0.3rem); +} + +.site-is-this-ai__avatar--overflow { + background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); + color: hsl(var(--km-color-fg)); + font-size: 0.9rem; + box-shadow: 0 0 0 2px hsl(var(--km-color-bg)); +} + +.site-is-this-ai__avatar--you { + box-shadow: 0 0 0 2px hsl(var(--km-color-primary)); +} + +.site-is-this-ai__host-panel { + /* margin-top: 1.25rem; + padding-top: 1.25rem; */ + display: flex; + gap: 0.5rem; +} + +.site-is-this-ai__swipe-deck { + position: relative; + height: 420px; + margin: 3rem 0; + touch-action: pan-y; +} + +.site-is-this-ai__swipe-card { + position: absolute; + inset: 0; + display: block; + box-sizing: border-box; + padding: 2.25rem; + background-color: hsl(var(--km-color-code-bg, var(--km-color-muted-bg))); + border: 1px solid hsl(var(--km-color-code-border, var(--km-color-border))); + border-radius: var(--km-radius-normal, 1.25rem); + white-space: pre-wrap; + font-size: 0.95rem; + line-height: 1.6; + overflow-y: auto; + cursor: grab; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + transition-property: transform, opacity; + transition-timing-function: var(--km-ease-smooth, ease); + transition-duration: var(--km-duration-normal, 200ms); +} + +.site-is-this-ai__swipe-card:active { + cursor: grabbing; +} + +.site-is-this-ai__swipe-card--dragging, +.site-is-this-ai__swipe-card--fresh { + transition: none; +} + +.site-is-this-ai__swipe-card--stack-1 { + transform: translate(0.9rem, 0.6rem) rotate(-6deg) scale(0.96); + z-index: 1; + opacity: 0.6; +} + +.site-is-this-ai__swipe-card--stack-2 { + transform: translate(-1.4rem, 1.2rem) rotate(8deg) scale(0.93); + z-index: 0; + opacity: 0.35; +} + +.site-is-this-ai__swipe-card--top { + z-index: 2; + transform: rotate(-1.5deg); +} + +.site-is-this-ai__results-end-image { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 100%; + max-height: 100%; + border-radius: var(--km-radius-normal, 1.25rem); + cursor: pointer; +} + +.site-is-this-ai__swipe-card--fly-left { + transform: translate(-140%, -8%) rotate(-24deg) !important; + opacity: 0; +} + +.site-is-this-ai__swipe-card--fly-right { + transform: translate(140%, -8%) rotate(24deg) !important; + opacity: 0; +} + +.site-is-this-ai__swipe-hint { + position: absolute; + top: 50%; + padding: 0.4rem 0.9rem; + border-radius: var(--km-radius-normal, 0.5rem); + border: 3px solid currentColor; + font-size: 1.15rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; + opacity: 0; + pointer-events: none; + z-index: 3; + transition: opacity var(--km-duration-fast, 120ms) var(--km-ease-smooth, ease); +} + +.site-is-this-ai__swipe-hint--ai { + left: 1.5rem; + color: hsl(var(--km-color-red, 0 70% 55%)); + transform: rotate(-12deg); +} + +.site-is-this-ai__swipe-hint--human { + right: 1.5rem; + color: hsl(var(--km-color-green, 140 55% 45%)); + transform: rotate(12deg); +} + +.site-is-this-ai__swipe-instructions { + color: hsl(var(--km-color-muted-fg)); + font-size: 1.05rem; + font-weight: 400; + margin-top: 1rem; + line-height: 1.5rem; + letter-spacing: initial; + width: 90%; +} + +.site-is-this-ai__voting-tally { + font-size: 0.85rem; + color: hsl(var(--km-color-fg) / 0.7); + margin: 1rem 0; +} + +.site-is-this-ai__swipe-deck--results { + margin-bottom: 5rem; +} + +.site-is-this-ai__results-tally { + margin: 0 0 1.5rem 0; +} + +.site-is-this-ai__results-tally-bar { + display: flex; + width: 100%; + height: 0.5rem; + border-radius: var(--km-radius-round, 500px); + overflow: hidden; + background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); + margin-bottom: 0.75rem; +} + +.site-is-this-ai__results-tally-bar-fill { + height: 100%; + min-width: 0; + transition: flex-grow var(--km-duration-normal, 200ms) var(--km-ease-smooth, ease); +} + +.site-is-this-ai__results-tally-bar-fill--ai { + background-color: hsl(var(--km-color-red, 0 70% 55%)); +} + +.site-is-this-ai__results-tally-bar-fill--human { + background-color: hsl(var(--km-color-green, 140 55% 45%)); +} + +.site-is-this-ai__results-tally-pills { + display: flex; + justify-content: center; + gap: 0.6rem; +} + +.site-is-this-ai__results-tally-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.4rem 0.9rem; + border-radius: var(--km-radius-round, 500px); + font-size: 0.85rem; + font-weight: 500; +} + +.site-is-this-ai__results-tally-pill b { + font-weight: 700; + margin-left: 1rem; +} + +.site-is-this-ai__results-tally-pill--ai { + background-color: hsl(var(--km-color-red, 0 70% 55%) / 0.12); + color: hsl(var(--km-color-red, 0 70% 55%)); +} + +.site-is-this-ai__results-tally-pill--human { + background-color: hsl(var(--km-color-green, 140 55% 45%) / 0.12); + color: hsl(var(--km-color-green, 140 55% 45%)); +} + diff --git a/docs/source/_static/js/is-this-ai.js b/docs/source/_static/js/is-this-ai.js new file mode 100644 index 0000000..f4ebfb7 --- /dev/null +++ b/docs/source/_static/js/is-this-ai.js @@ -0,0 +1,1249 @@ +/* +Peter, Is this AI? widget behaviour +===================================== + +Author: Akshay Mestry +Created on: 12 August, 2026 +Last updated on: 18 August, 2026 +*/ +(function() { + const SIGNALR_CLIENT_CDN_URL = 'https://cdn.jsdelivr.net/npm/@microsoft/signalr@8.0.7/dist/browser/signalr.min.js'; + + function ensureSignalRLoaded() { + if (window.signalR) return Promise.resolve(); + if (window.__isThisAISignalRPromise) return window.__isThisAISignalRPromise; + window.__isThisAISignalRPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = SIGNALR_CLIENT_CDN_URL; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load SignalR client.')); + document.head.appendChild(script); + }); + return window.__isThisAISignalRPromise; + } + + function createMockBackend() { + const FAKE_NAMES = ['QuietOtter', 'AmberFalcon', 'SlyPanda', 'BriskHeron', 'LuckyLynx', 'MellowSparrow', 'BoldBadger', 'EagerWeasel', 'FuzzyIbis', 'CalmMarmot']; + const ROOMS_STORAGE_KEY = 'is-this-ai-mock-rooms'; + const DEFAULT_ROOM_CODE = 'ABCDE'; + let listeners = {}; + + function loadRooms() { + try { + return JSON.parse(localStorage.getItem(ROOMS_STORAGE_KEY)) || {}; + } catch { + return {}; + } + } + + function saveRooms(rooms) { + localStorage.setItem(ROOMS_STORAGE_KEY, JSON.stringify(rooms)); + } + + let pendingEmits = null; + + function withRoom(roomCode, fn) { + const rooms = loadRooms(); + pendingEmits = []; + let result; + try { + result = fn(rooms, rooms[roomCode]); + } finally { + const queued = pendingEmits; + pendingEmits = null; + saveRooms(rooms); + queued.forEach(([event, args]) => { + (listeners[event] || []).forEach((cb) => cb(...args)); + }); + } + return result; + } + + function emit(event, ...args) { + if (pendingEmits) { + pendingEmits.push([event, args]); + } else { + (listeners[event] || []).forEach((cb) => cb(...args)); + } + } + + function randomRoomCode() { + return DEFAULT_ROOM_CODE; + } + + function randomToken() { + return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); + } + + function publicParticipant(p) { + return { + participantId: p.participantId, + displayName: p.displayName, + isHost: p.isHost, + }; + } + + function broadcastPresence(room) { + emit('PresenceUpdated', { + participants: room.participants.map(publicParticipant), + joinedCount: room.participants.length, + expectedHeadcount: room.expectedHeadcount, + }); + } + + function maybeAdvanceToSubmission(room) { + if (room.participants.length >= room.expectedHeadcount && room.phase === 'lobby') { + startSubmissionPhase(room); + } + } + + function startSubmissionPhase(room) { + room.phase = 'submission'; + emit('PhaseChanged', { + phase: 'submission' + }); + } + + function mySubmissionsFor(room, participantId) { + return Object.values(room.submissions) + .filter((s) => s.participantId === participantId) + .sort((a, b) => a.createdAt - b.createdAt); + } + + function submitContentFor(room, participantId, content, submissionId) { + if (submissionId) { + const existing = room.submissions[submissionId]; + if (!existing || existing.participantId !== participantId) { + throw new Error('Submission not found.'); + } + existing.content = content; + } else { + submissionId = 'sub-' + randomToken(); + room.submissions[submissionId] = { + submissionId, + participantId, + content, + createdAt: Date.now(), + }; + } + const submittedParticipants = new Set(Object.values(room.submissions).map((s) => s.participantId)).size; + emit('SubmissionArrived', { + submittedCount: submittedParticipants, + totalParticipants: room.participants.length, + skippedCount: room.skippedCount || 0, + }); + return submissionId; + } + + function startVotingPhase(room) { + room.phase = 'voting'; + const submissionIds = Object.keys(room.submissions); + room.participants.forEach((p) => { + const order = submissionIds.filter((id) => room.submissions[id].participantId !== p.participantId); + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + room.votingOrder[p.participantId] = order; + room.votingIndex[p.participantId] = 0; + }); + emit('PhaseChanged', { + phase: 'voting' + }); + } + + function votingItemPayload(room, participantId) { + const order = room.votingOrder[participantId] || []; + const index = room.votingIndex[participantId] || 0; + if (index >= order.length) { + return { + itemIndex: index, + totalItems: order.length, + content: null, + upcoming: [], + done: true + }; + } + const upcoming = order + .slice(index + 1, index + 3) + .map((id) => room.submissions[id].content); + return { + itemIndex: index, + totalItems: order.length, + content: room.submissions[order[index]].content, + upcoming, + done: false, + }; + } + + function allDoneVoting(room) { + return room.participants.every((p) => (room.votingIndex[p.participantId] || 0) >= (room.votingOrder[p.participantId] || []).length); + } + + function castVoteFor(room, voterParticipantId, verdict, reason) { + const order = room.votingOrder[voterParticipantId] || []; + const index = room.votingIndex[voterParticipantId] || 0; + if (index >= order.length) return votingItemPayload(room, voterParticipantId); + const submissionId = order[index]; + room.votes[submissionId] = room.votes[submissionId] || []; + const voter = room.participants.find((p) => p.participantId === voterParticipantId); + room.votes[submissionId].push({ + voterDisplayName: voter ? voter.displayName : 'Unknown', + verdict, + reason + }); + room.votingIndex[voterParticipantId] = index + 1; + emit('VoteTallyUpdated', { + votedCount: room.votes[submissionId].length, + totalParticipants: room.participants.length, + }); + if (allDoneVoting(room)) { + room.phase = 'results'; + emit('PhaseChanged', { + phase: 'results' + }); + emit('ResultsReady', { + roomCode: room.roomCode + }); + } + return votingItemPayload(room, voterParticipantId); + } + + function stopRoom(rooms, room) { + delete rooms[room.roomCode]; + emit('RoomStopped', {}); + emit('RoomRestarted', {}); + } + + const api = { + async login(body) { + if (body.username === 'demo' && body.password === 'demo') { + return { + ok: true, + hostToken: randomToken() + }; + } + const err = new Error('Invalid credentials.'); + throw err; + }, + + async createRoom(body) { + const roomCode = randomRoomCode(); + const hostParticipantId = 'host-' + randomToken(); + withRoom(roomCode, (rooms) => { + rooms[roomCode] = { + roomCode, + expectedHeadcount: body.expectedHeadcount, + phase: 'lobby', + participants: [{ + participantId: hostParticipantId, + displayName: 'Host', + isHost: true + }], + submissions: {}, + votingOrder: {}, + votingIndex: {}, + votes: {}, + }; + }); + return { + roomCode, + participantId: hostParticipantId, + sessionToken: 'session-' + hostParticipantId, + isHost: true, + }; + }, + + async joinRoom(roomCode, body) { + const participantId = 'guest-' + randomToken(); + const displayName = (body.displayName || '').trim() || FAKE_NAMES[Math.floor(Math.random() * FAKE_NAMES.length)]; + withRoom(roomCode, (rooms, room) => { + if (!room) throw new Error('Room not found.'); + if (room.phase !== 'lobby') throw new Error('This room has already started.'); + room.participants.push({ + participantId, + displayName, + isHost: false + }); + broadcastPresence(room); + maybeAdvanceToSubmission(room); + }); + return { + participantId, + sessionToken: 'session-' + participantId, + displayName, + isHost: false, + }; + }, + + async getState(roomCode, participantId) { + const room = loadRooms()[roomCode]; + if (!room) throw new Error('Room not found.'); + const participant = room.participants.find((p) => p.participantId === participantId); + if (!participant) throw new Error('Invalid session.'); + const submittedParticipants = new Set(Object.values(room.submissions).map((s) => s.participantId)).size; + return { + phase: room.phase, + participants: room.participants.map(publicParticipant), + expectedHeadcount: room.expectedHeadcount, + isHost: participant.isHost, + participantId, + submittedCount: submittedParticipants, + skippedCount: room.skippedCount || 0, + mySubmissions: mySubmissionsFor(room, participantId).map((s) => ({ + submissionId: s.submissionId, + content: s.content, + })), + }; + }, + + async forceStartSubmission(roomCode) { + withRoom(roomCode, (rooms, room) => { + if (!room) throw new Error('Room not found.'); + if (room.phase !== 'lobby') throw new Error('Room is not in the lobby phase.'); + room.skippedCount = Math.max(0, room.expectedHeadcount - room.participants.length); + broadcastPresence(room); + startSubmissionPhase(room); + }); + return { + ok: true + }; + }, + + async submitContent(roomCode, participantId, content, submissionId) { + let savedId; + withRoom(roomCode, (rooms, room) => { + if (!room) throw new Error('Room not found.'); + if (room.phase !== 'submission') throw new Error('Room is not accepting submissions.'); + savedId = submitContentFor(room, participantId, content, submissionId); + }); + return { + ok: true, + submissionId: savedId, + }; + }, + + async forceStartVoting(roomCode) { + withRoom(roomCode, (rooms, room) => { + if (!room) throw new Error('Room not found.'); + if (room.phase !== 'submission') throw new Error('Room is not in the submission phase.'); + if (!Object.keys(room.submissions).length) throw new Error('No submissions to vote on yet.'); + startVotingPhase(room); + }); + return { + ok: true + }; + }, + + async getVotingItem(roomCode, participantId) { + const room = loadRooms()[roomCode]; + if (!room) throw new Error('Room not found.'); + if (room.phase !== 'voting') throw new Error('Room is not in the voting phase.'); + return votingItemPayload(room, participantId); + }, + + async castVote(roomCode, participantId, verdict, reason) { + let payload; + withRoom(roomCode, (rooms, room) => { + if (!room) throw new Error('Room not found.'); + if (room.phase !== 'voting') throw new Error('Room is not in the voting phase.'); + payload = castVoteFor(room, participantId, verdict, reason); + }); + return payload; + }, + + async getResults(roomCode) { + const room = loadRooms()[roomCode]; + if (!room) throw new Error('Room not found.'); + if (room.phase !== 'results') throw new Error('Results are not ready yet.'); + const items = Object.keys(room.submissions).map((submissionId) => { + const votes = room.votes[submissionId] || []; + return { + content: room.submissions[submissionId].content, + aiVotes: votes.filter((v) => v.verdict === 'ai').length, + humanVotes: votes.filter((v) => v.verdict === 'human').length, + votes, + }; + }); + return { + items + }; + }, + + async stop(roomCode) { + withRoom(roomCode, (rooms, room) => { + if (!room) throw new Error('Room not found.'); + stopRoom(rooms, room); + }); + return { + ok: true + }; + }, + }; + + function fakeConnection() { + let onExternalChange = null; + function handleStorage(event) { + if (event.key !== ROOMS_STORAGE_KEY || !onExternalChange) return; + let rooms = {}; + try { + rooms = JSON.parse(event.newValue) || {}; + } catch { + rooms = {}; + } + onExternalChange(rooms); + } + return { + on(event, cb) { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + }, + onreconnecting() {}, + onreconnected() {}, + onclose() {}, + onExternalChange(cb) { + onExternalChange = cb; + }, + async start() { + window.addEventListener('storage', handleStorage); + }, + async stop() { + window.removeEventListener('storage', handleStorage); + }, + }; + } + + return { + api, + fakeConnection + }; + } + + function initIsThisAI(root) { + const UID = root.id; + const IS_LOCALHOST = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(window.location.hostname); + const LOCAL_DEV = root.dataset.localDev === 'true' || IS_LOCALHOST; + const USE_MOCK_BACKEND = LOCAL_DEV && root.dataset.localDevRealBackend !== 'true'; + const API_BASE = LOCAL_DEV + ? (root.dataset.localApiBaseUrl || 'http://localhost:7071/api') + : (root.dataset.apiBaseUrl || ''); + const HEADCOUNT_MAX = Number(root.dataset.headcountMax || 20); + const STORAGE_KEY = 'is-this-ai-session-' + UID; + const $ = (id) => document.getElementById(UID + '-' + id); + const mock = USE_MOCK_BACKEND ? createMockBackend() : null; + + let hostToken = null; + let roomCode = null; + let participantId = null; + let sessionToken = null; + let isHost = false; + let myDisplayName = null; + let connection = null; + let resultsItems = []; + let resultsIndex = 0; + let resultsDismissed = false; + let mySubmissions = []; + let mySubmissionCursor = 0; + let mySubmissionsHydrated = false; + + function showPhase(phase) { + ['choice', 'lobby', 'submission', 'voting', 'results'].forEach((p) => { + const el = $('phase-' + p); + if (el) el.classList.toggle('site-is-this-ai--hidden', p !== phase); + }); + setManualResyncVisibility(phase); + } + + function setStatus(message) { + const el = $('connection-status'); + if (el) el.textContent = message || ''; + } + + function setManualResyncVisibility(phase) { + const button = $('manual-resync'); + if (!button) return; + const inSession = Boolean(roomCode && sessionToken); + button.classList.toggle( + 'site-is-this-ai--hidden', + !inSession || phase === 'choice', + ); + } + + function resetHostChoiceFields() { + $('host-login-fields').classList.remove('site-is-this-ai--hidden'); + $('host-create-fields').classList.add('site-is-this-ai--hidden'); + $('login-error').textContent = ''; + $('choice-host-error').textContent = ''; + } + + function saveSession() { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ + roomCode, + participantId, + sessionToken, + isHost, + myDisplayName, + })); + } + + function clearSession() { + sessionStorage.removeItem(STORAGE_KEY); + roomCode = participantId = sessionToken = myDisplayName = null; + isHost = false; + mySubmissions = []; + mySubmissionCursor = 0; + mySubmissionsHydrated = false; + resetHostChoiceFields(); + setManualResyncVisibility('choice'); + } + + async function mockApi(path, options) { + options = options || {}; + const body = options.body ? JSON.parse(options.body) : {}; + const roomMatch = path.match(/^\/rooms\/([^/]+)(\/.*)?$/); + const code = roomMatch ? roomMatch[1] : null; + const sub = roomMatch ? (roomMatch[2] || '') : null; + + if (path === '/login') return mock.api.login(body); + if (path === '/rooms' && (options.method || 'GET') === 'POST') return mock.api.createRoom(body); + if (code && sub === '/join') return mock.api.joinRoom(code, body); + if (code && sub === '/state') return mock.api.getState(code, participantId); + if (code && sub === '/force-start-submission') return mock.api.forceStartSubmission(code); + if (code && sub === '/submit') return mock.api.submitContent(code, participantId, body.content, body.submissionId); + if (code && sub === '/force-start-voting') return mock.api.forceStartVoting(code); + if (code && sub === '/voting-item') return mock.api.getVotingItem(code, participantId); + if (code && sub === '/vote') return mock.api.castVote(code, participantId, body.verdict, body.reason); + if (code && sub === '/results') return mock.api.getResults(code); + if (code && sub === '/stop') return mock.api.stop(code); + if (code && sub === '/restart') return mock.api.stop(code); + if (code && sub === '/join-groups') return { + ok: true + }; + if (path === '/negotiate') return { + url: 'mock://local-dev', + accessToken: 'mock' + }; + throw new Error('Local dev mock: no handler for ' + path); + } + + async function api(path, options) { + if (USE_MOCK_BACKEND) return mockApi(path, options); + options = options || {}; + const headers = Object.assign({ + 'Content-Type': 'application/json' + }, options.headers || {}); + const response = await fetch(API_BASE + path, Object.assign({}, options, { + headers + })); + let body = null; + try { + body = await response.json(); + } catch { + /* empty body is fine */ + } + if (!response.ok) { + const message = (body && body.error) || ('Request failed (' + response.status + ')'); + throw new Error(message); + } + return body; + } + + $('login-submit').addEventListener('click', async () => { + const username = $('login-username').value.trim(); + const password = $('login-password').value; + $('login-error').textContent = ''; + try { + const result = await api('/login', { + method: 'POST', + body: JSON.stringify({ + username, + password + }), + }); + hostToken = result.hostToken; + $('host-login-fields').classList.add('site-is-this-ai--hidden'); + $('host-create-fields').classList.remove('site-is-this-ai--hidden'); + } catch (err) { + $('login-error').textContent = err.message; + } + }); + + $('choice-create').addEventListener('click', async () => { + $('choice-host-error').textContent = ''; + const headcount = parseInt($('choice-headcount').value, 10); + if (!headcount || headcount < 1 || headcount > HEADCOUNT_MAX) { + $('choice-host-error').textContent = 'Enter a headcount from 1 to ' + HEADCOUNT_MAX + '.'; + return; + } + try { + const result = await api('/rooms', { + method: 'POST', + body: JSON.stringify({ + hostToken, + expectedHeadcount: headcount + }), + }); + roomCode = result.roomCode; + participantId = result.participantId; + sessionToken = result.sessionToken; + isHost = true; + myDisplayName = 'Host'; + saveSession(); + await enterLobby(); + } catch (err) { + $('choice-host-error').textContent = err.message; + } + }); + + $('choice-join').addEventListener('click', async () => { + $('choice-join-error').textContent = ''; + const code = $('choice-room-code').value.trim().toUpperCase(); + if (!code) { + $('choice-join-error').textContent = 'Enter a room code.'; + return; + } + try { + const result = await api('/rooms/' + code + '/join', { + method: 'POST', + body: JSON.stringify({ + displayName: null + }), + }); + roomCode = code; + participantId = result.participantId; + sessionToken = result.sessionToken; + isHost = false; + myDisplayName = result.displayName; + saveSession(); + await enterLobby(); + } catch (err) { + $('choice-join-error').textContent = err.message; + } + }); + + async function enterLobby() { + await connectRealtime(); + await resyncState(); + } + + function initialsFor(displayName) { + const words = (displayName || '').trim().split(/\s+/).filter(Boolean); + if (!words.length) return '?'; + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + return (words[0][0] + words[words.length - 1][0]).toUpperCase(); + } + + function avatarColorFor(seed) { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + const hue = hash % 360; + return 'hsl(' + hue + ', 65%, 45%)'; + } + + const AVATAR_STACK_LIMIT = 8; + + function renderParticipants(participants, expectedHeadcount) { + const list = $('lobby-participants'); + list.innerHTML = ''; + list.classList.toggle('site-is-this-ai__avatar-stack--host', isHost); + const ordered = [ + ...participants.filter((p) => p.participantId === participantId), + ...participants.filter((p) => p.participantId !== participantId), + ]; + const visible = ordered.slice(0, AVATAR_STACK_LIMIT); + const overflowCount = ordered.length - visible.length; + visible.forEach((p, index) => { + const isMe = p.participantId === participantId; + const li = document.createElement('li'); + let className = 'site-is-this-ai__avatar'; + if (isMe) className += ' site-is-this-ai__avatar--you'; + li.className = className; + li.style.backgroundColor = avatarColorFor(p.participantId); + li.style.zIndex = String(visible.length - index); + li.textContent = isMe ? 'ME' : initialsFor(p.displayName); + li.title = isMe ? 'Me' : p.displayName; + list.appendChild(li); + }); + if (overflowCount > 0) { + const li = document.createElement('li'); + li.className = 'site-is-this-ai__avatar site-is-this-ai__avatar--overflow'; + li.style.zIndex = '0'; + li.textContent = '+' + overflowCount; + li.title = overflowCount + ' more'; + list.appendChild(li); + } + $('lobby-count').textContent = participants.length + ' of ' + expectedHeadcount + ' joined for ' + roomCode; + $('lobby-host-panel').classList.toggle('site-is-this-ai--hidden', !isHost); + } + + $('lobby-start-submission').addEventListener('click', async () => { + try { + await api('/rooms/' + roomCode + '/force-start-submission', { + method: 'POST', + body: JSON.stringify({ + sessionToken + }), + }); + await resyncState(); + } catch (err) { + setStatus(err.message); + } + }); + + async function stopSessionForEveryone() { + if (!roomCode) return; + if (!window.confirm('This ends the session for everyone and returns them to the start. Continue?')) return; + try { + await api('/rooms/' + roomCode + '/stop', { + method: 'POST', + body: JSON.stringify({ + sessionToken + }), + }); + clearSession(); + showPhase('choice'); + } catch (err) { + setStatus(err.message); + } + } + + $('lobby-stop-session').addEventListener('click', stopSessionForEveryone); + $('submission-stop-session').addEventListener('click', stopSessionForEveryone); + $('voting-stop-session').addEventListener('click', stopSessionForEveryone); + + function renderSubmissionNav() { + const nav = $('submission-nav'); + if (mySubmissions.length < 2) { + nav.classList.add('site-is-this-ai--hidden'); + return; + } + nav.classList.remove('site-is-this-ai--hidden'); + const total = mySubmissions.length + 1; + $('submission-nav-label').textContent = (mySubmissionCursor + 1) + ' of ' + total; + $('submission-nav-prev').disabled = mySubmissionCursor === 0; + $('submission-nav-next').disabled = mySubmissionCursor >= total - 1; + } + + function loadSubmissionAtCursor() { + const textarea = $('submission-textarea'); + const current = mySubmissions[mySubmissionCursor]; + textarea.value = current ? current.content : ''; + $('submission-status').textContent = current ? 'Submitted. You can keep editing until the host starts voting.' : ''; + renderSubmissionNav(); + } + + $('submission-nav-prev').addEventListener('click', () => { + if (mySubmissionCursor === 0) return; + mySubmissionCursor -= 1; + loadSubmissionAtCursor(); + }); + + $('submission-nav-next').addEventListener('click', () => { + if (mySubmissionCursor >= mySubmissions.length) return; + mySubmissionCursor += 1; + loadSubmissionAtCursor(); + }); + + $('submission-textarea').addEventListener('input', () => { + $('submission-status').textContent = ''; + }); + + $('submission-submit').addEventListener('click', async () => { + $('submission-error').textContent = ''; + $('submission-status').textContent = ''; + const content = $('submission-textarea').value.trim(); + if (!content) { + $('submission-error').textContent = 'Paste or enter something.'; + return; + } + const editing = mySubmissions[mySubmissionCursor]; + try { + const result = await api('/rooms/' + roomCode + '/submit', { + method: 'POST', + body: JSON.stringify({ + sessionToken, + content, + submissionId: editing ? editing.submissionId : undefined, + }), + }); + if (editing) { + editing.content = content; + $('submission-status').textContent = 'Submitted. You can keep editing until the host starts voting.'; + renderSubmissionNav(); + } else { + mySubmissions.push({ + submissionId: result.submissionId, + content + }); + mySubmissionCursor = mySubmissions.length; + loadSubmissionAtCursor(); + $('submission-status').textContent = 'Submitted! You can make another submission, or edit a previous one until the host starts voting.'; + } + } catch (err) { + $('submission-error').textContent = err.message; + } + }); + + $('submission-start-voting').addEventListener('click', async () => { + try { + await api('/rooms/' + roomCode + '/force-start-voting', { + method: 'POST', + body: JSON.stringify({ + sessionToken + }), + }); + await resyncState(); + } catch (err) { + setStatus(err.message); + } + }); + + function renderSubmissionProgress(submittedCount, totalParticipants, skippedCount) { + let text = submittedCount + ' of ' + totalParticipants + ' submitted'; + if (skippedCount > 0) { + text += '; ' + skippedCount + (skippedCount === 1 ? ' user was skipped' : ' users were skipped'); + } + $('submission-host-count').textContent = text; + } + + function enterSubmissionPhase() { + showPhase('submission'); + $('submission-host-panel').classList.toggle('site-is-this-ai--hidden', !isHost); + $('submission-host-count').classList.toggle('site-is-this-ai--hidden', !isHost); + $('submission-status').classList.toggle('site-is-this-ai--hidden', isHost); + } + + const SWIPE_THRESHOLD = 100; + const SWIPE_MAX_ROTATION = 18; + let swipeVoting = false; + + const RESULTS_END_IMAGES = [ + 'https://static0.srcdn.com/wordpress/wp-content/uploads/2019/03/Brian-Griffin-in-Family-GUy-3.jpg', + 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSDMR6yiAS6UVo79PUZMoLS0S83qkhVYNcLfJOXL7rYu3bKO5xJCY_TwwY&s=10', + 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR1RvgJtjgxOQ6rQH4qa8vPhCVjtD2JipHqYOQFEClSctxCGvp_FP3-EfE&s=10', + ]; + + function buildSwipeCard(content, stackLevel) { + const card = document.createElement('div'); + card.className = 'site-is-this-ai__swipe-card site-is-this-ai__swipe-card--fresh'; + if (stackLevel === 0) { + card.classList.add('site-is-this-ai__swipe-card--top'); + } else { + card.classList.add('site-is-this-ai__swipe-card--stack-' + stackLevel); + } + card.textContent = content; + return card; + } + + function releaseFreshCard(card) { + void card.getBoundingClientRect(); + card.classList.remove('site-is-this-ai__swipe-card--fresh'); + } + + function renderCardStack(deck, contents, attachTopSwipe) { + const freshCards = []; + for (let i = contents.length - 1; i >= 0; i--) { + const card = buildSwipeCard(contents[i], i); + deck.appendChild(card); + freshCards.push(card); + if (i === 0) attachTopSwipe(card); + } + freshCards.forEach(releaseFreshCard); + } + + const SWIPE_FLY_DURATION = 220; + + function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function attachSwipeGesture(card, deck, options) { + const hintAi = options.hintAi || null; + const hintHuman = options.hintHuman || null; + let startX = 0; + let rawDx = 0; + let dx = 0; + let dragging = false; + let locked = false; + + let restLeft = 0; + let restRight = 0; + + function onPointerDown(event) { + if (locked || (options.isLocked && options.isLocked())) return; + dragging = true; + startX = event.clientX; + const restRect = card.getBoundingClientRect(); + restLeft = restRect.left; + restRight = restRect.right; + card.classList.add('site-is-this-ai__swipe-card--dragging'); + card.setPointerCapture(event.pointerId); + } + + function onPointerMove(event) { + if (!dragging) return; + const viewportWidth = document.documentElement.clientWidth; + const maxDragLeft = restLeft; + const maxDragRight = viewportWidth - restRight; + rawDx = event.clientX - startX; + dx = Math.max(-maxDragLeft, Math.min(maxDragRight, rawDx)); + const rotation = Math.max(-1, Math.min(1, dx / 300)) * SWIPE_MAX_ROTATION; + card.style.transform = 'translateX(' + dx + 'px) rotate(' + rotation + 'deg)'; + const pull = Math.min(Math.abs(rawDx) / SWIPE_THRESHOLD, 1); + if (hintAi) hintAi.style.opacity = rawDx < 0 ? String(pull) : '0'; + if (hintHuman) hintHuman.style.opacity = rawDx > 0 ? String(pull) : '0'; + } + + function onPointerUp() { + if (!dragging) return; + dragging = false; + card.classList.remove('site-is-this-ai__swipe-card--dragging'); + if (hintAi) hintAi.style.opacity = '0'; + if (hintHuman) hintHuman.style.opacity = '0'; + if (Math.abs(rawDx) >= SWIPE_THRESHOLD) { + const direction = rawDx > 0 ? 'right' : 'left'; + const flyClass = rawDx > 0 ? 'site-is-this-ai__swipe-card--fly-right' : 'site-is-this-ai__swipe-card--fly-left'; + card.classList.add(flyClass); + card.style.transform = ''; + const consumed = options.onSwipe(direction, card); + if (consumed === false) { + card.classList.remove(flyClass); + } else { + locked = true; + } + } else { + card.style.transform = ''; + } + rawDx = 0; + dx = 0; + } + + card.addEventListener('pointerdown', onPointerDown); + card.addEventListener('pointermove', onPointerMove); + card.addEventListener('pointerup', onPointerUp); + card.addEventListener('pointercancel', onPointerUp); + } + + async function castVoteAndAdvance(verdict, card) { + if (swipeVoting) return; + swipeVoting = true; + $('voting-error').textContent = ''; + try { + const [next] = await Promise.all([ + api('/rooms/' + roomCode + '/vote', { + method: 'POST', + body: JSON.stringify({ + sessionToken, + verdict, + reason: '' + }), + }), + wait(SWIPE_FLY_DURATION), + ]); + renderVotingItem(next); + } catch (err) { + $('voting-error').textContent = err.message; + card.classList.remove('site-is-this-ai__swipe-card--fly-left', 'site-is-this-ai__swipe-card--fly-right'); + } finally { + swipeVoting = false; + } + } + + function attachVotingSwipe(card) { + const deck = $('voting-deck'); + const hintAi = deck.querySelector('.site-is-this-ai__swipe-hint--ai'); + const hintHuman = deck.querySelector('.site-is-this-ai__swipe-hint--human'); + attachSwipeGesture(card, deck, { + hintAi, + hintHuman, + isLocked: () => swipeVoting, + onSwipe: (direction) => { + const verdict = direction === 'right' ? 'human' : 'ai'; + castVoteAndAdvance(verdict, card); + }, + }); + } + + function renderVotingItem(data) { + $('voting-host-panel').classList.toggle('site-is-this-ai--hidden', !isHost); + if (data.done) { + $('voting-active').classList.add('site-is-this-ai--hidden'); + $('voting-waiting').classList.remove('site-is-this-ai--hidden'); + return; + } + $('voting-active').classList.remove('site-is-this-ai--hidden'); + $('voting-waiting').classList.add('site-is-this-ai--hidden'); + $('voting-error').textContent = ''; + + const deck = $('voting-deck'); + deck.querySelectorAll('.site-is-this-ai__swipe-card').forEach((card) => card.remove()); + const stack = [data.content, ...(data.upcoming || [])]; + renderCardStack(deck, stack, attachVotingSwipe); + } + + async function loadResults() { + $('results-host-panel').classList.toggle('site-is-this-ai--hidden', !isHost); + try { + const result = await api('/rooms/' + roomCode + '/results', { + headers: { + 'x-session-token': sessionToken + }, + }); + resultsItems = result.items; + resultsIndex = 0; + resultsDismissed = false; + renderResultsItem(); + } catch (err) { + setStatus(err.message); + } + } + + function attachResultsSwipe(card) { + const deck = $('results-deck'); + attachSwipeGesture(card, deck, { + onSwipe: (direction) => { + if (direction === 'left') { + if (resultsIndex === 0) return false; + resultsIndex -= 1; + renderResultsItem(); + return true; + } + if (resultsIndex >= resultsItems.length - 1) { + resultsDismissed = true; + renderResultsItem(); + return true; + } + resultsIndex += 1; + renderResultsItem(); + return true; + }, + }); + } + + function renderResultsItem() { + const deck = $('results-deck'); + deck.querySelectorAll('.site-is-this-ai__swipe-card').forEach((card) => card.remove()); + if (!resultsItems.length) { + $('results-tally').classList.add('site-is-this-ai--hidden'); + deck.appendChild(buildSwipeCard('No submissions.', 0)); + return; + } + if (resultsDismissed) { + $('results-tally').classList.add('site-is-this-ai--hidden'); + const img = document.createElement('img'); + img.src = RESULTS_END_IMAGES[Math.floor(Math.random() * RESULTS_END_IMAGES.length)]; + img.alt = "That's everyone -- tap to see results again."; + img.className = 'site-is-this-ai__results-end-image'; + img.addEventListener('click', () => { + resultsDismissed = false; + resultsIndex = resultsItems.length - 1; + renderResultsItem(); + }); + deck.appendChild(img); + return; + } + $('results-tally').classList.remove('site-is-this-ai--hidden'); + const item = resultsItems[resultsIndex]; + const total = item.aiVotes + item.humanVotes; + const aiPercent = total ? (item.aiVotes / total) * 100 : 50; + const humanPercent = total ? 100 - aiPercent : 50; + $('results-tally-bar-ai').style.flexGrow = String(aiPercent); + $('results-tally-bar-human').style.flexGrow = String(humanPercent); + $('results-tally-ai-count').textContent = String(item.aiVotes); + $('results-tally-human-count').textContent = String(item.humanVotes); + + const stack = resultsItems.slice(resultsIndex, resultsIndex + 3).map((i) => i.content); + renderCardStack(deck, stack, attachResultsSwipe); + } + + async function connectRealtime() { + if (connection) return; + if (USE_MOCK_BACKEND) { + connection = mock.fakeConnection(); + } else { + await ensureSignalRLoaded(); + if (!window.signalR) { + setStatus('SignalR failed to load. Refresh and try again.'); + return; + } + const negotiateInfo = await api('/negotiate', { + method: 'POST', + headers: { + 'x-participant-id': participantId + }, + body: JSON.stringify({ + roomCode, + participantId + }), + }).catch(() => null); + if (!negotiateInfo) { + setStatus('Could not connect to realtime updates. Refresh to retry.'); + return; + } + connection = new window.signalR.HubConnectionBuilder() + .withUrl(negotiateInfo.url, { + accessTokenFactory: () => negotiateInfo.accessToken + }) + .withAutomaticReconnect() + .build(); + } + + connection.on('PresenceUpdated', (data) => renderParticipants(data.participants, data.expectedHeadcount)); + connection.on('PhaseChanged', async (data) => { + if (data.phase === 'lobby') showPhase('lobby'); + if (data.phase === 'submission') enterSubmissionPhase(); + if (data.phase === 'voting') { + showPhase('voting'); + const item = await api('/rooms/' + roomCode + '/voting-item', { + headers: { + 'x-session-token': sessionToken + }, + }); + renderVotingItem(item); + } + if (data.phase === 'results') { + showPhase('results'); + loadResults(); + } + }); + connection.on('SubmissionArrived', (data) => { + if (!isHost) return; + renderSubmissionProgress(data.submittedCount, data.totalParticipants, data.skippedCount); + }); + connection.on('VoteTallyUpdated', (data) => { + $('voting-tally').textContent = data.votedCount + ' of ' + data.totalParticipants + ' voted on that item'; + $('voting-tally').classList.toggle('site-is-this-ai--hidden', !isHost); + }); + connection.on('ResultsReady', () => loadResults()); + connection.on('RoomStopped', () => { + clearSession(); + window.location.reload(); + }); + connection.on('RoomRestarted', () => { + clearSession(); + window.location.reload(); + }); + + async function joinSignalRGroups() { + await api('/rooms/' + roomCode + '/join-groups', { + method: 'POST', + body: JSON.stringify({ + sessionToken, + participantId + }), + }); + } + + connection.onreconnecting(() => setStatus('Reconnecting...')); + connection.onreconnected(async () => { + await joinSignalRGroups(); + setStatus(''); + await resyncState(); + }); + if (USE_MOCK_BACKEND && connection.onExternalChange) { + connection.onExternalChange(async (rooms) => { + if (!rooms[roomCode]) { + clearSession(); + window.location.reload(); + return; + } + await resyncState(); + }); + } + connection.onclose(() => setStatus('Disconnected. Refresh the page to rejoin.')); + await connection.start(); + await joinSignalRGroups(); + } + + async function resyncState() { + try { + const state = await api('/rooms/' + roomCode + '/state', { + headers: { + 'x-session-token': sessionToken + }, + }); + isHost = state.isHost; + const me = state.participants.find((p) => p.participantId === participantId); + if (me) { + myDisplayName = me.displayName; + saveSession(); + } + renderParticipants(state.participants, state.expectedHeadcount); + if (state.phase === 'lobby') showPhase('lobby'); + if (state.phase === 'submission') { + enterSubmissionPhase(); + renderSubmissionProgress(state.submittedCount, state.participants.length, state.skippedCount); + if (!mySubmissionsHydrated) { + mySubmissionsHydrated = true; + mySubmissions = state.mySubmissions || []; + mySubmissionCursor = mySubmissions.length ? mySubmissions.length - 1 : 0; + loadSubmissionAtCursor(); + } + } + if (state.phase === 'voting') { + showPhase('voting'); + const item = await api('/rooms/' + roomCode + '/voting-item', { + headers: { + 'x-session-token': sessionToken + }, + }); + renderVotingItem(item); + } + if (state.phase === 'results') { + showPhase('results'); + loadResults(); + } + } catch (err) { + setStatus(err.message); + } + } + + if ($('manual-resync')) { + $('manual-resync').addEventListener('click', async () => { + if (!roomCode) return; + setStatus('Refreshing...'); + await resyncState(); + setStatus(''); + }); + } + + $('restart-session').addEventListener('click', stopSessionForEveryone); + + (async function resumeIfPossible() { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (!saved) { + showPhase('choice'); + return; + } + try { + const parsed = JSON.parse(saved); + roomCode = parsed.roomCode; + participantId = parsed.participantId; + sessionToken = parsed.sessionToken; + isHost = parsed.isHost; + myDisplayName = parsed.myDisplayName || null; + await connectRealtime(); + await resyncState(); + } catch { + clearSession(); + showPhase('choice'); + } + })(); + } + + function bootIsThisAI() { + document.querySelectorAll('.site-is-this-ai[data-is-this-ai="true"]').forEach(initIsThisAI); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootIsThisAI, { + once: true + }); + } else { + bootIsThisAI(); + } +})(); diff --git a/docs/source/academia/is-this-ai.rst b/docs/source/academia/is-this-ai.rst new file mode 100644 index 0000000..3db7d8c --- /dev/null +++ b/docs/source/academia/is-this-ai.rst @@ -0,0 +1,42 @@ +.. Author: Akshay Mestry +.. Created on: 11 August, 2026 +.. Last updated on: 18 August, 2026 + +:og:title: Is this AI? +:og:description: A completely scientific* investigation into whether (real) + humans can actually tell AI writing from human writing. +:og:type: article +:fb:title: What did you think of this idea? +:fb:description: Make your guess. Tell me why. +:fb:button: Let me know! + +.. _academia-is-this-ai: + +=============================================================================== +:fas:`robot far` Is this AI? +=============================================================================== + +.. rst-class:: lead + + People think they can tell AI writing from human writing, they're probably + wrong. + +.. author:: @xames3 + :avatar: https://avatars.githubusercontent.com/u/90549089?v=4 + :target: https://github.com/xames3 + :background: https://m.media-amazon.com/images/M/MV5BMzFiYjI5YWItNjUwOC00MzllLWIwODItMGE0ZWNhODJkOTA0XkEyXkFqcGc@._V1_.jpg + https://static.wikia.nocookie.net/familyguyfanon/images/0/04/Clash_for_Mayors.jpg + +This is a small live experiment: gather a few people, everyone pastes in a +chunk of text, and then everyone votes on whether each piece was written by a +human or by an AI, with their optional reasons. + +.. iframe:: + :uid: km-css230 + :api-base-url: https://func-slopcode-294281165.azurewebsites.net/api + :local-api-base-url: http://localhost:7071/api + :local-dev: false + :expected-headcount-max: 20 + :file: ../assets/html/is-this-ai.html + :css: css/is-this-ai.css + :js: js/is-this-ai.js diff --git a/docs/source/assets/html/is-this-ai.html b/docs/source/assets/html/is-this-ai.html new file mode 100644 index 0000000..9603351 --- /dev/null +++ b/docs/source/assets/html/is-this-ai.html @@ -0,0 +1,282 @@ + +
+
+
+ + +
+
+
+

+ AI → Actual Imposter +

+

Who's an AI Among Us

+

Join with a room code.

+
+
+
+ +
+
+ +

+
+
+
+
+ + +
+
+
+

+ AI → Actual Imposter +

+

Who's an AI Among Us

+

Sign in to host your own room.

+
+
+
+
+ +
+
+ +
+
+ +

+
+
+
+ + +

+
+
+
+
+
+
+
+

+ It's fun when many participate. +

+
+

Let's wait for others to join.

+

+
    +
+
+ + +
+
+
+
+

+ Type. Edit. Paste. +

+
+

+ Enter your  slop   text. +

+

+ Nobody else can see this until voting starts. You can keep editing and + resubmitting until the host starts voting. +

+
+
+ +

+
+ + + +
+ +
+
+

+

+
+ + +
+
+
+
+

+ Guess wisely. +

+
+

Let's vote.

+

+ Swipe right for human, left for AI +

+
+
+
+ AI +
+
+ HUMAN +
+
+

+
+
+

+ You're done voting. Waiting for everyone else to finish... +

+
+

+
+ +
+
+
+
+

+ Results. +

+
+

The verdict is in.

+
+
+
+
+
+
+
+ + + AI 0 + + + + Human 0 + +
+
+
+ +
+
+
+

+ +
diff --git a/docs/source/conf.py b/docs/source/conf.py index 219b949..fa91ad4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 22 February, 2025 -Last updated on: 28 June, 2026 +Last updated on: 12 August, 2026 This file contains the configuration settings for building my static website using Sphinx, a popular Python documentation tool. Sphinx is a diff --git a/docs/source/index.rst b/docs/source/index.rst index 2487c74..da70c9a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,6 +1,6 @@ .. Author: Akshay Mestry .. Created on: 22 February, 2025 -.. Last updated on: 28 June, 2026 +.. Last updated on: 12 August, 2026 :orphan: :og:title: Akshay's Corner @@ -166,7 +166,7 @@ and my position, when asked for any help. :titlesonly: academia/thoughts-on-teaching - miscellany/slopcode + academia/is-this-ai .. toctree:: :caption: Explained diff --git a/docs/source/miscellany/slopcode.rst b/docs/source/miscellany/slopcode.rst deleted file mode 100644 index f564960..0000000 --- a/docs/source/miscellany/slopcode.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. Author: Akshay Mestry -.. Created on: 03 August, 2026 -.. Last updated on: 03 August, 2026 - -:og:title: Slopcode -:og:description: Can you tell human writing from AI writing? A live, - multiplayer game to find out. -:og:type: article - -.. _miscellany-slopcode: - -=============================================================================== -:fas:`robot far` Slopcode -=============================================================================== - -.. rst-class:: lead - - Can you tell human writing from AI writing? Let's find out, live. - -This is a small live experiment: gather a few people, everyone pastes in a -chunk of text, and then everyone votes on whether each piece was written by a -human or by an AI -- with a reason. Host controls the pace; everyone sees the -results at the end. - -.. slopcode:: - :expected-headcount-max: 20 diff --git a/kaamiki/extensions/__init__.py b/kaamiki/extensions/__init__.py index b7669cf..a0dc5a6 100644 --- a/kaamiki/extensions/__init__.py +++ b/kaamiki/extensions/__init__.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 22 February, 2025 -Last updated on: 04 May, 2026 +Last updated on: 18 August, 2026 This module manages Kaamiki Sphinx Theme's custom directive and roles. @@ -20,9 +20,9 @@ from . import author from . import button +from . import iframe from . import picture from . import repository -from . import slopcode from . import thumbnail from . import video from . import youtube @@ -33,9 +33,9 @@ directives: t.Sequence[types.ModuleType] = ( author, button, + iframe, picture, repository, - slopcode, thumbnail, video, youtube, diff --git a/kaamiki/extensions/iframe.py b/kaamiki/extensions/iframe.py new file mode 100644 index 0000000..4337b08 --- /dev/null +++ b/kaamiki/extensions/iframe.py @@ -0,0 +1,227 @@ +"""\ +iFrame Directive +================ + +Author: Akshay Mestry +Created on: 11 August, 2026 +Last updated on: 18 August, 2026 + +This module defines a custom `iframe` directive for the Kaamiki Sphinx +Theme. The directive allows adding an (optionally) customisable iframe +directly within the document. + +The `iframe` directive can be used in reStructuredText documents as +follows:: + + .. code-block:: rst + + .. iframe:: + :expected-count: 10 + + + +To keep large embeds out of the page's own reST source, the directive +also accepts a `:file:` option pointing at an external HTML fragment. +That fragment may in turn need its own CSS and/or JavaScript. Rather +than inlining `\n" - f"{html_shell}\n" - f'\n' - f"\n" - f"\n" - ) - - -# ---------------------------------------------------------------------- -# CSS -# ---------------------------------------------------------------------- -# -# New `.site-slopcode*` BEM blocks, matching the theme's existing -# naming convention (`.site-youtube-card`, `.site-feedback-shell--split`, -# etc.) and reusing the site's existing design tokens (`--km-color-*`, -# `--km-radius-normal`, `--km-ease-smooth`, ...) rather than inventing -# a new visual language. Scoped entirely under `.site-slopcode` so it -# can't leak into or collide with the rest of the theme's CSS. - -_CSS_TEMPLATE: t.Final[str] = """ -.site-slopcode { - max-width: var(--km-layout-container, 861px); - margin: 2rem auto; - font-family: var(--km-font-sans, inherit); - color: hsl(var(--km-color-fg)); -} - -.site-slopcode--hidden { - display: none !important; -} - -.site-slopcode__card { - background-color: hsl(var(--km-color-surface)); - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.75rem; - transition-property: opacity, transform; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-normal, 200ms); -} - -.site-slopcode__title { - font-size: 1.15rem; - font-weight: 600; - margin: 0 0 1rem 0; - color: hsl(var(--km-color-fg-strong)); -} - -.site-slopcode__subtitle { - font-size: 0.9rem; - color: hsl(var(--km-color-fg) / 0.7); - margin: -0.5rem 0 1rem 0; -} - -.site-slopcode__field { - display: flex; - flex-direction: column; - gap: 0.35rem; - margin-bottom: 1rem; -} - -.site-slopcode__label { - font-size: 0.85rem; - font-weight: 500; -} - -.site-slopcode__input, -.site-slopcode__textarea { - background-color: hsl(var(--km-color-input, var(--km-color-bg))); - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 0.6rem 0.75rem; - color: hsl(var(--km-color-fg)); - font-family: inherit; - font-size: 0.95rem; -} - -.site-slopcode__textarea { - min-height: 140px; - resize: vertical; -} - -.site-slopcode__button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - background-color: hsl(var(--km-color-primary)); - color: hsl(var(--km-color-bg)); - border: none; - border-radius: var(--km-radius-round, 500px); - padding: 0.55rem 1.25rem; - font-size: 0.9rem; - font-weight: 600; - cursor: pointer; - transition-property: opacity, transform; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-fast, 120ms); -} - -.site-slopcode__button:hover { - opacity: 0.9; -} - -.site-slopcode__button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.site-slopcode__button--secondary { - background-color: transparent; - color: hsl(var(--km-color-fg)); - border: 1px solid hsl(var(--km-color-border)); -} - -.site-slopcode__error { - color: hsl(var(--km-color-red, 0 70% 55%)); - font-size: 0.85rem; - margin-top: 0.5rem; - min-height: 1.1rem; -} - -.site-slopcode__lobby-share { - display: flex; - align-items: center; - gap: 0.6rem; - background-color: hsl(var(--km-color-accent-bg)); - color: hsl(var(--km-color-accent-fg)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 0.75rem 1rem; - margin-bottom: 1.25rem; - font-family: var(--km-font-mono, monospace); - font-size: 1.1rem; - letter-spacing: 0.08em; -} - -.site-slopcode__lobby-count { - font-size: 0.85rem; - color: hsl(var(--km-color-fg) / 0.7); - margin-bottom: 0.75rem; -} - -.site-slopcode__participant-list { - list-style: none; - margin: 0 0 1.25rem 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.site-slopcode__participant { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); - font-size: 0.9rem; -} - -.site-slopcode__participant--host::after { - content: "Host"; - margin-left: auto; - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: hsl(var(--km-color-primary)); -} - -.site-slopcode__participant--you { - border: 1px solid hsl(var(--km-color-primary)); -} - -.site-slopcode__participant--you::before { - content: "You:"; - font-weight: 600; - color: hsl(var(--km-color-primary)); -} - -.site-slopcode__host-panel { - border-top: 1px dashed hsl(var(--km-color-border)); - margin-top: 1.25rem; - padding-top: 1.25rem; -} - -.site-slopcode__host-panel-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: hsl(var(--km-color-fg) / 0.6); - margin-bottom: 0.5rem; -} - -.site-slopcode__voting-content { - background-color: hsl(var(--km-color-code-bg, var(--km-color-muted-bg))); - border: 1px solid hsl(var(--km-color-code-border, var(--km-color-border))); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.25rem; - margin-bottom: 1.25rem; - white-space: pre-wrap; - font-size: 0.95rem; - line-height: 1.6; - max-height: 400px; - overflow-y: auto; -} - -.site-slopcode__voting-progress { - font-size: 0.8rem; - color: hsl(var(--km-color-fg) / 0.6); - margin-bottom: 1rem; -} - -.site-slopcode__voting-actions { - display: flex; - gap: 0.75rem; - margin-bottom: 1rem; -} - -.site-slopcode__voting-verdict-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border-radius: var(--km-radius-normal, 0.75rem); - border: 2px solid hsl(var(--km-color-border)); - background-color: transparent; - color: hsl(var(--km-color-fg)); - padding: 0.75rem; - font-weight: 600; - cursor: pointer; - transition-property: border-color, background-color; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-fast, 120ms); -} - -.site-slopcode__voting-verdict-btn--ai.site-slopcode__voting-verdict-btn--selected { - border-color: hsl(var(--km-color-red, 0 70% 55%)); - background-color: hsl(var(--km-color-red, 0 70% 55%) / 0.12); -} - -.site-slopcode__voting-verdict-btn--human.site-slopcode__voting-verdict-btn--selected { - border-color: hsl(var(--km-color-green, 140 55% 45%)); - background-color: hsl(var(--km-color-green, 140 55% 45%) / 0.12); -} - -.site-slopcode__voting-tally { - font-size: 0.85rem; - color: hsl(var(--km-color-fg) / 0.7); - margin: 1rem 0; -} - -.site-slopcode__host-vote-feed { - list-style: none; - margin: 0.5rem 0 0 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.4rem; - max-height: 220px; - overflow-y: auto; -} - -.site-slopcode__host-vote-feed-item { - font-size: 0.85rem; - padding: 0.5rem 0.75rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); -} - -.site-slopcode__results-item { - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.25rem; - margin-bottom: 1.25rem; -} - -.site-slopcode__results-item-content { - white-space: pre-wrap; - font-size: 0.9rem; - line-height: 1.6; - margin-bottom: 0.75rem; - max-height: 250px; - overflow-y: auto; -} - -.site-slopcode__results-item-tally { - display: flex; - gap: 1.25rem; - font-size: 0.9rem; - font-weight: 600; - margin-bottom: 0.75rem; -} - -.site-slopcode__results-item-votes { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.site-slopcode__results-item-vote { - font-size: 0.8rem; - color: hsl(var(--km-color-fg) / 0.8); - padding: 0.4rem 0.6rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); -} - -.site-slopcode__radio-group { - display: flex; - gap: 1rem; - margin-bottom: 1rem; -} - -.site-slopcode__radio-label { - display: flex; - align-items: center; - gap: 0.4rem; - font-size: 0.9rem; - cursor: pointer; -} -""" - - -# ---------------------------------------------------------------------- -# HTML shell -# ---------------------------------------------------------------------- -# -# Five phase containers, all present from first load, toggled by JS -# via `.site-slopcode--hidden`. `{uid}` scopes every element id so two -# invocations of this directive on one page (not the current plan, but -# free to support given the existing theme's per-instance-uid -# precedent in the archived interpreter extension) wouldn't collide. - -_HTML_TEMPLATE: t.Final[str] = """ -
-

Slopcode

-

Got a room code? Join below -- no sign-in needed.

-
- - -
-
- - -
- -

- -
-

Hosting? Sign in to create a room

-
- - -
-
- - -
- -

- -
- - - -
-
-
- -
-

Waiting room

-
Room code:
-

You are:

-

-
    -
    -

    Host controls

    - -
    -
    - -
    -

    Paste your text

    -

    Nobody else can see this until voting starts.

    -
    - -
    - -

    -
    -

    Host controls

    -

    - -
    -
    - -
    -

    Vote: AI or Human?

    -
    -

    -
    -
    - - -
    -
    - - -
    - -

    -
    -
    -

    You're done voting. Waiting for everyone else to finish...

    -
    -

    -
    -

    Host view -- votes as they arrive

    -
      -
      -
      - -
      -

      Results

      -

      -
      -
      - -
        -
        - - -
        -
        -

        Host controls

        - -
        -
        - -

        - -""" - - -# ---------------------------------------------------------------------- -# Client-side JS -# ---------------------------------------------------------------------- -# -# Plain vanilla JS, no bundler/build step (this repo has none, and -# Alpine.js' availability elsewhere in the theme isn't reliable enough -# to depend on here) -- state lives in a handful of top-level `let` -# bindings, phases are toggled via one `showPhase()` function, and the -# only "polling" that ever happens is a single one-shot resync call -# right after a dropped SignalR connection reconnects -- never a -# periodic timer, since a forgotten open tab polling for days is -# exactly the kind of abuse the backend's cost guardrails are meant to -# rule out. - -_JS_TEMPLATE: t.Final[str] = """ -(function() {{ - const UID = "{uid}"; - const API_BASE = "{api_base_url}"; - const HEADCOUNT_MAX = {headcount_max}; - const STORAGE_KEY = "slopcode-session-" + UID; - - const $ = (id) => document.getElementById(UID + "-" + id); - - let hostToken = null; - let roomCode = null; - let participantId = null; - let sessionToken = null; - let isHost = false; - let myDisplayName = null; - let hasSubmitted = false; - let selectedVerdict = null; - let connection = null; - let resultsItems = []; - let resultsIndex = 0; - let resultsShowingComments = false; - - function showPhase(phase) {{ - ["choice", "lobby", "submission", "voting", "results"].forEach((p) => {{ - const el = document.getElementById(UID + "-phase-" + p); - if (el) el.classList.toggle("site-slopcode--hidden", p !== phase); - }}); - }} - - function setStatus(message) {{ - const el = $("connection-status"); - if (el) el.textContent = message || ""; - }} - - function saveSession() {{ - sessionStorage.setItem(STORAGE_KEY, JSON.stringify({{ - roomCode, participantId, sessionToken, isHost, myDisplayName, - }})); - }} - - function clearSession() {{ - sessionStorage.removeItem(STORAGE_KEY); - roomCode = participantId = sessionToken = myDisplayName = null; - isHost = false; - }} - - async function api(path, options) {{ - options = options || {{}}; - const headers = Object.assign({{ "Content-Type": "application/json" }}, options.headers || {{}}); - const response = await fetch(API_BASE + path, Object.assign({{}}, options, {{ headers }})); - let body = null; - try {{ body = await response.json(); }} catch (e) {{ /* empty body is fine */ }} - if (!response.ok) {{ - const message = (body && body.error) || ("Request failed (" + response.status + ")"); - throw new Error(message); - }} - return body; - }} - - // ---- Login ---- - - $("login-submit").addEventListener("click", async () => {{ - const username = $("login-username").value.trim(); - const password = $("login-password").value; - $("login-error").textContent = ""; - try {{ - const result = await api("/login", {{ - method: "POST", - body: JSON.stringify({{ username, password }}), - }}); - hostToken = result.hostToken; - $("host-create-fields").classList.remove("site-slopcode--hidden"); - $("login-submit").disabled = true; - $("login-username").disabled = true; - $("login-password").disabled = true; - }} catch (err) {{ - $("login-error").textContent = err.message; - }} - }}); - - // ---- Create / join room ---- - - $("choice-create").addEventListener("click", async () => {{ - $("choice-error").textContent = ""; - const headcount = parseInt($("choice-headcount").value, 10); - if (!headcount || headcount < 1 || headcount > HEADCOUNT_MAX) {{ - $("choice-error").textContent = "Enter a headcount from 1 to " + HEADCOUNT_MAX + "."; - return; - }} - try {{ - const result = await api("/rooms", {{ - method: "POST", - body: JSON.stringify({{ hostToken, expectedHeadcount: headcount }}), - }}); - roomCode = result.roomCode; - participantId = result.participantId; - sessionToken = result.sessionToken; - isHost = true; - myDisplayName = "Host"; - saveSession(); - await enterLobby(); - }} catch (err) {{ - $("choice-error").textContent = err.message; - }} - }}); - - $("choice-join").addEventListener("click", async () => {{ - $("choice-error").textContent = ""; - const code = $("choice-room-code").value.trim().toUpperCase(); - const displayName = $("choice-display-name").value.trim(); - if (!code) {{ - $("choice-error").textContent = "Enter a room code."; - return; - }} - try {{ - const result = await api("/rooms/" + code + "/join", {{ - method: "POST", - body: JSON.stringify({{ displayName: displayName || null }}), - }}); - roomCode = code; - participantId = result.participantId; - sessionToken = result.sessionToken; - isHost = false; - myDisplayName = result.displayName; - saveSession(); - await enterLobby(); - }} catch (err) {{ - $("choice-error").textContent = err.message; - }} - }}); - - // ---- Lobby ---- - - async function enterLobby() {{ - await connectRealtime(); - // resyncState() sets the correct phase itself (lobby, submission, - // or voting -- e.g. if this join was the last one needed and the - // room already auto-started by the time this client catches up). - // Don't force "lobby" afterward; that would silently overwrite it. - await resyncState(); - }} - - function renderParticipants(participants, expectedHeadcount) {{ - const list = $("lobby-participants"); - list.innerHTML = ""; - participants.forEach((p) => {{ - const li = document.createElement("li"); - let className = "site-slopcode__participant"; - if (p.isHost) className += " site-slopcode__participant--host"; - if (p.participantId === participantId) className += " site-slopcode__participant--you"; - li.className = className; - li.textContent = p.displayName; - list.appendChild(li); - }}); - $("lobby-count").textContent = participants.length + " of " + expectedHeadcount + " joined"; - $("lobby-room-code").textContent = roomCode; - $("my-display-name").textContent = myDisplayName || ""; - $("lobby-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - }} - - $("lobby-start-submission").addEventListener("click", async () => {{ - try {{ - await api("/rooms/" + roomCode + "/force-start-submission", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - // Don't wait on the PhaseChanged broadcast to update our own - // screen -- the action already succeeded server-side, so pull - // fresh state directly rather than depend on a round-trip - // message that may be delayed, dropped, or racing a fresh - // SignalR connection that hasn't finished joining its group yet. - await resyncState(); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - // ---- Submission ---- - - $("submission-submit").addEventListener("click", async () => {{ - $("submission-error").textContent = ""; - const content = $("submission-textarea").value.trim(); - if (!content) {{ - $("submission-error").textContent = "Paste something first."; - return; - }} - try {{ - await api("/rooms/" + roomCode + "/submit", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, content }}), - }}); - hasSubmitted = true; - $("submission-submit").disabled = true; - $("submission-textarea").disabled = true; - }} catch (err) {{ - $("submission-error").textContent = err.message; - }} - }}); - - $("submission-start-voting").addEventListener("click", async () => {{ - try {{ - await api("/rooms/" + roomCode + "/force-start-voting", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - await resyncState(); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - function enterSubmissionPhase() {{ - showPhase("submission"); - // Mirrors the equivalent toggle in renderVotingItem() for the - // voting phase's host panel -- without this, the host-only live - // submission count and "Start voting phase" button stay - // permanently hidden, since the section starts hidden in the - // markup and nothing else ever un-hides it. - $("submission-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - }} - - // ---- Voting ---- - - function selectVerdict(verdict) {{ - selectedVerdict = verdict; - $("voting-vote-ai").classList.toggle("site-slopcode__voting-verdict-btn--selected", verdict === "ai"); - $("voting-vote-human").classList.toggle("site-slopcode__voting-verdict-btn--selected", verdict === "human"); - $("voting-submit").disabled = false; - }} - - $("voting-vote-ai").addEventListener("click", () => selectVerdict("ai")); - $("voting-vote-human").addEventListener("click", () => selectVerdict("human")); - - $("voting-submit").addEventListener("click", async () => {{ - $("voting-error").textContent = ""; - if (!selectedVerdict) return; - const reason = $("voting-reason").value.trim(); - $("voting-submit").disabled = true; - $("voting-vote-ai").disabled = true; - $("voting-vote-human").disabled = true; - $("voting-reason").disabled = true; - try {{ - const next = await api("/rooms/" + roomCode + "/vote", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, verdict: selectedVerdict, reason }}), - }}); - renderVotingItem(next); - }} catch (err) {{ - $("voting-error").textContent = err.message; - $("voting-submit").disabled = false; - $("voting-vote-ai").disabled = false; - $("voting-vote-human").disabled = false; - $("voting-reason").disabled = false; - }} - }}); - - function renderVotingItem(data) {{ - $("voting-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - - if (data.done) {{ - $("voting-active").classList.add("site-slopcode--hidden"); - $("voting-waiting").classList.remove("site-slopcode--hidden"); - return; - }} - $("voting-active").classList.remove("site-slopcode--hidden"); - $("voting-waiting").classList.add("site-slopcode--hidden"); - - $("voting-progress").textContent = "Item " + (data.itemIndex + 1) + " of " + data.totalItems; - $("voting-content").textContent = data.content; - selectedVerdict = null; - $("voting-vote-ai").classList.remove("site-slopcode__voting-verdict-btn--selected"); - $("voting-vote-human").classList.remove("site-slopcode__voting-verdict-btn--selected"); - $("voting-vote-ai").disabled = false; - $("voting-vote-human").disabled = false; - $("voting-reason").disabled = false; - $("voting-reason").value = ""; - $("voting-submit").disabled = true; - $("voting-error").textContent = ""; - }} - - // ---- Results ---- - - async function loadResults() {{ - $("results-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - try {{ - const result = await api("/rooms/" + roomCode + "/results", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - resultsItems = result.items; - resultsIndex = 0; - resultsShowingComments = false; - renderResultsItem(); - }} catch (err) {{ - setStatus(err.message); - }} - }} - - function renderResultsItem() {{ - if (!resultsItems.length) {{ - $("results-progress").textContent = "No submissions."; - $("results-content").textContent = ""; - $("results-tally").textContent = ""; - $("results-votes").innerHTML = ""; - $("results-prev").disabled = true; - $("results-next").disabled = true; - return; - }} - const item = resultsItems[resultsIndex]; - $("results-progress").textContent = "Item " + (resultsIndex + 1) + " of " + resultsItems.length; - $("results-content").textContent = item.content; - $("results-tally").textContent = "AI: " + item.aiVotes + " Human: " + item.humanVotes; - - const votesList = $("results-votes"); - votesList.innerHTML = ""; - item.votes.forEach((v) => {{ - const li = document.createElement("li"); - li.className = "site-slopcode__results-item-vote"; - li.textContent = v.voterDisplayName + " voted " + v.verdict + " -- \\"" + v.reason + "\\""; - votesList.appendChild(li); - }}); - votesList.classList.toggle("site-slopcode--hidden", !resultsShowingComments); - $("results-toggle-comments").textContent = resultsShowingComments ? "Hide comments" : "Read comments"; - - $("results-prev").disabled = resultsIndex === 0; - $("results-next").disabled = resultsIndex === resultsItems.length - 1; - }} - - $("results-toggle-comments").addEventListener("click", () => {{ - resultsShowingComments = !resultsShowingComments; - renderResultsItem(); - }}); - - $("results-prev").addEventListener("click", () => {{ - if (resultsIndex === 0) return; - resultsIndex -= 1; - resultsShowingComments = false; - renderResultsItem(); - }}); - - $("results-next").addEventListener("click", () => {{ - if (resultsIndex >= resultsItems.length - 1) return; - resultsIndex += 1; - resultsShowingComments = false; - renderResultsItem(); - }}); - - // ---- Realtime (SignalR) ---- - - async function connectRealtime() {{ - if (connection) return; - const negotiateInfo = await api("/negotiate", {{ - method: "POST", - headers: {{ "x-participant-id": participantId }}, - body: JSON.stringify({{ roomCode, participantId }}), - }}).catch(() => null); - if (!negotiateInfo) {{ - setStatus("Couldn't connect to realtime updates. Refresh to retry."); - return; - }} - connection = new signalR.HubConnectionBuilder() - .withUrl(negotiateInfo.url, {{ accessTokenFactory: () => negotiateInfo.accessToken }}) - .withAutomaticReconnect() - .build(); - - connection.on("PresenceUpdated", (data) => renderParticipants(data.participants, data.expectedHeadcount)); - connection.on("PhaseChanged", async (data) => {{ - if (data.phase === "submission") enterSubmissionPhase(); - if (data.phase === "voting") {{ - showPhase("voting"); - const item = await api("/rooms/" + roomCode + "/voting-item", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - renderVotingItem(item); - }} - if (data.phase === "results") {{ showPhase("results"); loadResults(); }} - }}); - connection.on("SubmissionArrived", (data) => {{ - if (!isHost) return; - $("submission-host-count").textContent = data.submittedCount + " of " + data.totalParticipants + " submitted"; - }}); - connection.on("VoteTallyUpdated", (data) => {{ - $("voting-tally").textContent = data.votedCount + " of " + data.totalParticipants + " voted on that item"; - }}); - connection.on("VoteCastHostView", (data) => {{ - if (!isHost) return; - const li = document.createElement("li"); - li.className = "site-slopcode__host-vote-feed-item"; - li.textContent = data.voterDisplayName + ": " + data.verdict + " -- \\"" + data.reason + "\\""; - $("voting-host-feed").appendChild(li); - }}); - connection.on("ResultsReady", () => loadResults()); - connection.on("RoomRestarted", () => {{ - clearSession(); - window.location.reload(); - }}); - - async function joinSignalRGroups() {{ - await api("/rooms/" + roomCode + "/join-groups", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, participantId }}), - }}); - }} - - connection.onreconnecting(() => setStatus("Reconnecting...")); - connection.onreconnected(async () => {{ - // A reconnect gets a brand-new underlying connection id -- group - // membership doesn't carry over, so without rejoining here this - // client would silently stop receiving any broadcast (phase - // changes, results, everything) until the page is manually - // reloaded, even though resyncState() below makes it LOOK caught - // up at this one instant. - await joinSignalRGroups(); - setStatus(""); - await resyncState(); - }}); - connection.onclose(() => setStatus("Disconnected. Refresh the page to rejoin.")); - - await connection.start(); - await joinSignalRGroups(); - }} - - async function resyncState() {{ - try {{ - const state = await api("/rooms/" + roomCode + "/state", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - isHost = state.isHost; - const me = state.participants.find((p) => p.participantId === participantId); - if (me) {{ - myDisplayName = me.displayName; - saveSession(); - }} - renderParticipants(state.participants, state.expectedHeadcount); - if (state.phase === "lobby") showPhase("lobby"); - if (state.phase === "submission") enterSubmissionPhase(); - if (state.phase === "voting") {{ - showPhase("voting"); - const item = await api("/rooms/" + roomCode + "/voting-item", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - renderVotingItem(item); - }} - if (state.phase === "results") {{ showPhase("results"); loadResults(); }} - }} catch (err) {{ - setStatus(err.message); - }} - }} - - $("manual-resync").addEventListener("click", async () => {{ - if (!roomCode) return; - setStatus("Refreshing..."); - await resyncState(); - setStatus(""); - }}); - - $("restart-session").addEventListener("click", async () => {{ - if (!window.confirm("This ends the session for everyone and returns them to the start. Continue?")) return; - try {{ - await api("/rooms/" + roomCode + "/restart", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - // ---- Resume a session across page refreshes ---- - - (async function resumeIfPossible() {{ - const saved = sessionStorage.getItem(STORAGE_KEY); - if (!saved) {{ showPhase("choice"); return; }} - try {{ - const parsed = JSON.parse(saved); - roomCode = parsed.roomCode; - participantId = parsed.participantId; - sessionToken = parsed.sessionToken; - isHost = parsed.isHost; - myDisplayName = parsed.myDisplayName || null; - await connectRealtime(); - // resyncState() sets the correct phase itself -- don't force - // "lobby" here, that would silently overwrite it (same bug - // fixed in enterLobby() above). - await resyncState(); - }} catch (err) {{ - clearSession(); - showPhase("choice"); - }} - }})(); -}})(); -""" diff --git a/pyproject.toml b/pyproject.toml index ad90901..da0cea2 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -321,6 +321,6 @@ commands = [ description = "Run HTML, CSS, and JS linting with djlint" deps = [ "djlint==1.36.4" ] commands = [ - [ "djlint", "kaamiki/base/templates", "docs/source/assets/html", "-e", "html", "--reformat" ], - [ "djlint", "kaamiki/base/templates", "-e", "html.jinja", "--reformat" ], + [ "djlint", "kaamiki/base/templates", "docs/source/_static/", "docs/source/assets/html", "-e", "html", "--reformat" ], + [ "djlint", "kaamiki/base/templates", "docs/source/_static/", "-e", "html.jinja", "--reformat" ], ]