extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add reaction lexicon for move comments

- Create boo.sky.go.reaction lexicon with move, emoji, text, stars fields
- Add ReactionRecord type to types.ts
- Add fetchMoveReactions and fetchGameReactions to atproto-client
- Create /api/games/[id]/reaction POST endpoint for creating reactions
- Add reactions panel to game page showing comments on selected move
- Display emoji badges on moves with reactions in move history
- Add reaction form with emoji, star rating, and text input
- Style reactions panel and form components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+670 -2
+1 -1
TODOS.md
··· 1 1 Features not yet implemented 2 - - [ ] new reaction lexicon that contains an optional emoji field, a required text field, an an optional stars field, and a mandatory "Move ID" field, referencing a move in a game that can be commented on by any logged in user who is spectating. These could be shown on the right side of the board, and moves can show a preview of the reaction emojis as badges 2 + - [x] new reaction lexicon that contains an optional emoji field, a required text field, an an optional stars field, and a mandatory "Move ID" field, referencing a move in a game that can be commented on by any logged in user who is spectating. These could be shown on the right side of the board, and moves can show a preview of the reaction emojis as badges 3 3 - [x] sharing a game using bsky post intents : The web URL endpoint is https://bsky.app/intent/compose, with the HTTP query parameter text. Remember to use URL-escaping on the query parameter value, and that the post length limit on Bluesky is 300 characters (more precisely, 300 Unicode Grapheme Clusters). 4 4 - [x] tag the players involved in the game and use a hashtag composed of the game ID 5 5 - [x] Opengraph image preview of the game
+41
lexicons/boo.sky.go.reaction.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "boo.sky.go.reaction", 4 + "description": "A reaction/comment on a specific move in a Go game", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["move", "text", "createdAt"], 12 + "properties": { 13 + "move": { 14 + "type": "string", 15 + "description": "AT URI reference to the move record being reacted to" 16 + }, 17 + "emoji": { 18 + "type": "string", 19 + "description": "Optional emoji reaction (single emoji)", 20 + "maxLength": 10 21 + }, 22 + "text": { 23 + "type": "string", 24 + "description": "Comment text about the move", 25 + "maxLength": 300 26 + }, 27 + "stars": { 28 + "type": "integer", 29 + "description": "Optional star rating for the move (1-5)", 30 + "minimum": 1, 31 + "maximum": 5 32 + }, 33 + "createdAt": { 34 + "type": "string", 35 + "format": "datetime" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }
+75 -1
src/lib/atproto-client.ts
··· 1 - import type { GameRecord, MoveRecord, PassRecord, ResignRecord } from './types'; 1 + import type { GameRecord, MoveRecord, PassRecord, ResignRecord, ReactionRecord } from './types'; 2 2 3 3 const CONSTELLATION_BASE = 'https://constellation.microcosm.blue/xrpc'; 4 4 const PLC_DIRECTORY = 'https://plc.directory'; ··· 295 295 296 296 return { moves, passes, resigns }; 297 297 } 298 + 299 + export interface ReactionWithAuthor extends ReactionRecord { 300 + uri: string; 301 + author: string; 302 + authorHandle?: string; 303 + } 304 + 305 + /** Fetch all reactions for a specific move from Constellation backlinks. */ 306 + export async function fetchMoveReactions( 307 + moveAtUri: string 308 + ): Promise<ReactionWithAuthor[]> { 309 + const reactions: ReactionWithAuthor[] = []; 310 + let cursor: string | undefined; 311 + 312 + try { 313 + do { 314 + const params = new URLSearchParams({ 315 + subject: moveAtUri, 316 + source: 'boo.sky.go.reaction:move', 317 + limit: '100', 318 + }); 319 + if (cursor) params.set('cursor', cursor); 320 + 321 + const res = await fetch( 322 + `${CONSTELLATION_BASE}/blue.microcosm.links.getBacklinks?${params}`, 323 + { headers: { Accept: 'application/json' } } 324 + ); 325 + 326 + if (!res.ok) break; 327 + 328 + const body: ConstellationBacklinksResponse = await res.json(); 329 + for (const rec of body.records) { 330 + // Parse author DID from the URI (at://did/collection/rkey) 331 + const uriParts = rec.uri.split('/'); 332 + const author = uriParts[2] || ''; 333 + reactions.push({ 334 + ...(rec.value as ReactionRecord), 335 + uri: rec.uri, 336 + author, 337 + }); 338 + } 339 + cursor = body.cursor ?? undefined; 340 + } while (cursor); 341 + } catch (err) { 342 + console.error('Failed to fetch reactions from Constellation:', err); 343 + } 344 + 345 + // Sort by creation time, newest first 346 + reactions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 347 + return reactions; 348 + } 349 + 350 + /** Fetch all reactions for all moves in a game. Returns a map of moveUri -> reactions. */ 351 + export async function fetchGameReactions( 352 + moveUris: string[] 353 + ): Promise<Map<string, ReactionWithAuthor[]>> { 354 + const reactionsByMove = new Map<string, ReactionWithAuthor[]>(); 355 + 356 + // Fetch reactions for all moves in parallel 357 + const results = await Promise.all( 358 + moveUris.map(async (uri) => { 359 + const reactions = await fetchMoveReactions(uri); 360 + return { uri, reactions }; 361 + }) 362 + ); 363 + 364 + for (const { uri, reactions } of results) { 365 + if (reactions.length > 0) { 366 + reactionsByMove.set(uri, reactions); 367 + } 368 + } 369 + 370 + return reactionsByMove; 371 + }
+9
src/lib/types.ts
··· 42 42 color: 'black' | 'white'; 43 43 createdAt: string; 44 44 } 45 + 46 + export interface ReactionRecord { 47 + $type: 'boo.sky.go.reaction'; 48 + move: string; // AT URI of the move being reacted to 49 + emoji?: string; 50 + text: string; 51 + stars?: number; // 1-5 52 + createdAt: string; 53 + }
+103
src/routes/api/games/[id]/reaction/+server.ts
··· 1 + import { json, error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getSession, getAgent } from '$lib/server/auth'; 4 + import { getDb } from '$lib/server/db'; 5 + 6 + function generateTid(): string { 7 + const timestamp = Date.now() * 1000; 8 + const clockid = Math.floor(Math.random() * 1024); 9 + const tid = timestamp.toString(32).padStart(11, '0') + clockid.toString(32).padStart(2, '0'); 10 + return tid; 11 + } 12 + 13 + export const POST: RequestHandler = async (event) => { 14 + const session = await getSession(event); 15 + const { params, request } = event; 16 + 17 + if (!session) { 18 + throw error(401, 'Not authenticated'); 19 + } 20 + 21 + const { id: rkey } = params; 22 + const { moveUri, text, emoji, stars } = await request.json(); 23 + 24 + if (!moveUri || typeof moveUri !== 'string') { 25 + throw error(400, 'Move URI is required'); 26 + } 27 + 28 + if (!text || typeof text !== 'string' || text.length > 300) { 29 + throw error(400, 'Text is required and must be under 300 characters'); 30 + } 31 + 32 + if (emoji && (typeof emoji !== 'string' || emoji.length > 10)) { 33 + throw error(400, 'Emoji must be a string under 10 characters'); 34 + } 35 + 36 + if (stars !== undefined && (typeof stars !== 'number' || stars < 1 || stars > 5)) { 37 + throw error(400, 'Stars must be a number between 1 and 5'); 38 + } 39 + 40 + try { 41 + const db = getDb(); 42 + 43 + // Verify the game exists 44 + const game = await db 45 + .selectFrom('games') 46 + .selectAll() 47 + .where('rkey', '=', rkey) 48 + .executeTakeFirst(); 49 + 50 + if (!game) { 51 + throw error(404, 'Game not found'); 52 + } 53 + 54 + const agent = await getAgent(event); 55 + if (!agent) { 56 + throw error(401, 'Failed to get authenticated agent'); 57 + } 58 + 59 + const reactionRkey = generateTid(); 60 + const now = new Date().toISOString(); 61 + 62 + const reactionRecord: Record<string, unknown> = { 63 + $type: 'boo.sky.go.reaction', 64 + move: moveUri, 65 + text: text.trim(), 66 + createdAt: now, 67 + }; 68 + 69 + if (emoji) { 70 + reactionRecord.emoji = emoji; 71 + } 72 + 73 + if (stars !== undefined) { 74 + reactionRecord.stars = stars; 75 + } 76 + 77 + const result = await (agent as any).post('com.atproto.repo.createRecord', { 78 + input: { 79 + repo: session.did, 80 + collection: 'boo.sky.go.reaction', 81 + rkey: reactionRkey, 82 + record: reactionRecord, 83 + }, 84 + }); 85 + 86 + if (!result.ok) { 87 + throw new Error(`Failed to create record: ${result.data.message}`); 88 + } 89 + 90 + return json({ 91 + success: true, 92 + uri: result.data.uri, 93 + reaction: { 94 + ...reactionRecord, 95 + uri: result.data.uri, 96 + author: session.did, 97 + }, 98 + }); 99 + } catch (err) { 100 + console.error('Failed to create reaction:', err); 101 + throw error(500, 'Failed to create reaction'); 102 + } 103 + };
+441
src/routes/game/[id]/+page.svelte
··· 7 7 resolveDidToHandle, 8 8 fetchGameRecord, 9 9 fetchGameActionsFromPds, 10 + fetchGameReactions, 11 + type ReactionWithAuthor, 10 12 } from '$lib/atproto-client'; 11 13 import type { MoveRecord, PassRecord, GameRecord, ResignRecord } from '$lib/types'; 12 14 import { onMount, onDestroy } from 'svelte'; ··· 23 25 let jetstreamConnected = $state(false); 24 26 let jetstreamError = $state(false); 25 27 let reviewMoveIndex = $state<number | null>(null); 28 + 29 + // Reaction state 30 + let reactions = $state<Map<string, ReactionWithAuthor[]>>(new Map()); 31 + let showReactionForm = $state(false); 32 + let reactionText = $state(''); 33 + let reactionEmoji = $state(''); 34 + let reactionStars = $state<number | undefined>(undefined); 35 + let isSubmittingReaction = $state(false); 36 + let reactionHandles = $state<Record<string, string>>({}); 26 37 27 38 // Client-side loaded data 28 39 let loading = $state(true); ··· 80 91 .reduce((sum, move) => sum + move.captureCount, 0); 81 92 }); 82 93 94 + // Get the currently selected move (for reactions panel) 95 + const selectedMove = $derived( 96 + reviewMoveIndex !== null ? moves[reviewMoveIndex] : (moves.length > 0 ? moves[moves.length - 1] : null) 97 + ); 98 + 99 + // Build move URI for reactions (format: at://did/boo.sky.go.move/rkey) 100 + function getMoveUri(move: MoveRecord): string { 101 + // The move URI is constructed from player DID and move details 102 + // Since we don't have the rkey directly, we use a compound key 103 + return `at://${move.player}/boo.sky.go.move/${move.moveNumber}`; 104 + } 105 + 106 + // Get reactions for the selected move 107 + const selectedMoveReactions = $derived(() => { 108 + if (!selectedMove) return []; 109 + // Try to find reactions using multiple URI formats 110 + for (const [uri, reacts] of reactions) { 111 + if (uri.includes(selectedMove.player) && uri.includes(`/${selectedMove.moveNumber}`)) { 112 + return reacts; 113 + } 114 + } 115 + return []; 116 + }); 117 + 118 + // Count reactions per move for badges 119 + function getReactionCount(move: MoveRecord): number { 120 + for (const [uri, reacts] of reactions) { 121 + if (uri.includes(move.player) && uri.includes(`/${move.moveNumber}`)) { 122 + return reacts.length; 123 + } 124 + } 125 + return 0; 126 + } 127 + 128 + // Get unique emojis for a move (for badges) 129 + function getMoveEmojis(move: MoveRecord): string[] { 130 + const emojis: string[] = []; 131 + for (const [uri, reacts] of reactions) { 132 + if (uri.includes(move.player) && uri.includes(`/${move.moveNumber}`)) { 133 + for (const r of reacts) { 134 + if (r.emoji && !emojis.includes(r.emoji)) { 135 + emojis.push(r.emoji); 136 + } 137 + } 138 + } 139 + } 140 + return emojis.slice(0, 3); // Max 3 emojis for badge display 141 + } 142 + 83 143 async function loadGameData() { 84 144 loading = true; 85 145 ··· 116 176 moves = result.moves; 117 177 passes = result.passes; 118 178 resigns = result.resigns; 179 + 180 + // Fetch reactions for all moves 181 + if (result.moves.length > 0) { 182 + const moveUris = result.moves.map((m) => getMoveUri(m)); 183 + const reactionsMap = await fetchGameReactions(moveUris); 184 + reactions = reactionsMap; 185 + 186 + // Resolve handles for reaction authors 187 + const authorDids = new Set<string>(); 188 + for (const reacts of reactionsMap.values()) { 189 + for (const r of reacts) { 190 + authorDids.add(r.author); 191 + } 192 + } 193 + for (const did of authorDids) { 194 + resolveDidToHandle(did).then((h) => { 195 + reactionHandles = { ...reactionHandles, [did]: h }; 196 + }); 197 + } 198 + } 119 199 120 200 loading = false; 121 201 } ··· 223 303 } 224 304 } 225 305 306 + async function handleReactionSubmit() { 307 + if (!selectedMove || !data.session || isSubmittingReaction) return; 308 + if (!reactionText.trim()) { 309 + alert('Please enter a comment'); 310 + return; 311 + } 312 + 313 + isSubmittingReaction = true; 314 + try { 315 + const moveUri = getMoveUri(selectedMove); 316 + const response = await fetch(`/api/games/${data.gameRkey}/reaction`, { 317 + method: 'POST', 318 + headers: { 'Content-Type': 'application/json' }, 319 + body: JSON.stringify({ 320 + moveUri, 321 + text: reactionText.trim(), 322 + emoji: reactionEmoji || undefined, 323 + stars: reactionStars, 324 + }), 325 + }); 326 + 327 + if (response.ok) { 328 + const result = await response.json(); 329 + // Add the new reaction to local state 330 + const newReaction: ReactionWithAuthor = { 331 + $type: 'boo.sky.go.reaction', 332 + move: moveUri, 333 + text: reactionText.trim(), 334 + emoji: reactionEmoji || undefined, 335 + stars: reactionStars, 336 + createdAt: new Date().toISOString(), 337 + uri: result.uri, 338 + author: data.session.did, 339 + }; 340 + 341 + const currentReactions = reactions.get(moveUri) || []; 342 + reactions = new Map(reactions).set(moveUri, [newReaction, ...currentReactions]); 343 + 344 + // Clear form 345 + reactionText = ''; 346 + reactionEmoji = ''; 347 + reactionStars = undefined; 348 + showReactionForm = false; 349 + } else { 350 + alert('Failed to post reaction'); 351 + } 352 + } catch (err) { 353 + console.error('Failed to post reaction:', err); 354 + alert('Failed to post reaction'); 355 + } finally { 356 + isSubmittingReaction = false; 357 + } 358 + } 359 + 226 360 onMount(() => { 227 361 loadGameData(); 228 362 ··· 630 764 <span class="captures">+{move.captureCount}</span> 631 765 {/if} 632 766 </span> 767 + {#if getMoveEmojis(move).length > 0 || getReactionCount(move) > 0} 768 + <span class="reaction-badge"> 769 + {#each getMoveEmojis(move) as emoji} 770 + <span class="reaction-emoji">{emoji}</span> 771 + {/each} 772 + {#if getReactionCount(move) > getMoveEmojis(move).length} 773 + <span class="reaction-count">+{getReactionCount(move) - getMoveEmojis(move).length}</span> 774 + {/if} 775 + </span> 776 + {/if} 633 777 </button> 634 778 {/each} 635 779 {#each passes as pass} ··· 650 794 </div> 651 795 </div> 652 796 {/if} 797 + 798 + <!-- Reactions Panel --> 799 + {#if moves.length > 0} 800 + <div class="reactions-panel cloud-card"> 801 + <div class="reactions-header"> 802 + <h3> 803 + Reactions 804 + {#if selectedMove} 805 + <span class="reactions-move-ref"> 806 + Move #{selectedMove.moveNumber} 807 + </span> 808 + {/if} 809 + </h3> 810 + {#if data.session && selectedMove} 811 + <button 812 + class="add-reaction-btn" 813 + onclick={() => showReactionForm = !showReactionForm} 814 + > 815 + {showReactionForm ? 'Cancel' : '+ Add Reaction'} 816 + </button> 817 + {/if} 818 + </div> 819 + 820 + {#if showReactionForm && data.session && selectedMove} 821 + <form class="reaction-form" onsubmit={(e) => { e.preventDefault(); handleReactionSubmit(); }}> 822 + <div class="reaction-form-row"> 823 + <input 824 + type="text" 825 + class="reaction-emoji-input" 826 + placeholder="Emoji" 827 + bind:value={reactionEmoji} 828 + maxlength="4" 829 + /> 830 + <select class="reaction-stars-select" bind:value={reactionStars}> 831 + <option value={undefined}>Stars</option> 832 + <option value={1}>1</option> 833 + <option value={2}>2</option> 834 + <option value={3}>3</option> 835 + <option value={4}>4</option> 836 + <option value={5}>5</option> 837 + </select> 838 + </div> 839 + <textarea 840 + class="reaction-text-input" 841 + placeholder="Comment on this move..." 842 + bind:value={reactionText} 843 + maxlength="300" 844 + rows="2" 845 + ></textarea> 846 + <button 847 + type="submit" 848 + class="submit-reaction-btn" 849 + disabled={isSubmittingReaction || !reactionText.trim()} 850 + > 851 + {isSubmittingReaction ? 'Posting...' : 'Post Reaction'} 852 + </button> 853 + </form> 854 + {/if} 855 + 856 + <div class="reactions-list"> 857 + {#if selectedMoveReactions().length === 0} 858 + <p class="no-reactions">No reactions yet. {data.session ? 'Be the first to comment!' : 'Login to add a reaction.'}</p> 859 + {:else} 860 + {#each selectedMoveReactions() as reaction} 861 + <div class="reaction-item"> 862 + <div class="reaction-item-header"> 863 + {#if reaction.emoji} 864 + <span class="reaction-item-emoji">{reaction.emoji}</span> 865 + {/if} 866 + <a 867 + href="https://bsky.app/profile/{reaction.author}" 868 + target="_blank" 869 + rel="noopener noreferrer" 870 + class="reaction-author" 871 + > 872 + {reactionHandles[reaction.author] || reaction.author.slice(0, 20)} 873 + </a> 874 + {#if reaction.stars} 875 + <span class="reaction-stars"> 876 + {'★'.repeat(reaction.stars)}{'☆'.repeat(5 - reaction.stars)} 877 + </span> 878 + {/if} 879 + </div> 880 + <p class="reaction-text">{reaction.text}</p> 881 + </div> 882 + {/each} 883 + {/if} 884 + </div> 885 + </div> 886 + {/if} 653 887 {:else} 654 888 <p class="waiting-message">Waiting for another player to join...</p> 655 889 {/if} ··· 1302 1536 .cancelled-text { 1303 1537 color: var(--sky-rose-dark); 1304 1538 font-weight: 600; 1539 + } 1540 + 1541 + /* Reaction badges on moves */ 1542 + .reaction-badge { 1543 + display: inline-flex; 1544 + align-items: center; 1545 + gap: 0.125rem; 1546 + margin-left: auto; 1547 + font-size: 0.75rem; 1548 + } 1549 + 1550 + .reaction-emoji { 1551 + font-size: 0.875rem; 1552 + } 1553 + 1554 + .reaction-count { 1555 + color: var(--sky-gray); 1556 + font-size: 0.7rem; 1557 + } 1558 + 1559 + .move-item.selected .reaction-count { 1560 + color: rgba(255, 255, 255, 0.8); 1561 + } 1562 + 1563 + /* Reactions panel */ 1564 + .reactions-panel { 1565 + background: var(--sky-white); 1566 + border-radius: 1rem; 1567 + padding: 1.5rem; 1568 + box-shadow: 0 4px 16px rgba(90, 122, 144, 0.08); 1569 + margin-top: 1.5rem; 1570 + border: 1px solid var(--sky-blue-pale); 1571 + } 1572 + 1573 + .reactions-header { 1574 + display: flex; 1575 + justify-content: space-between; 1576 + align-items: center; 1577 + margin-bottom: 1rem; 1578 + } 1579 + 1580 + .reactions-header h3 { 1581 + margin: 0; 1582 + color: var(--sky-slate-dark); 1583 + font-weight: 600; 1584 + display: flex; 1585 + align-items: center; 1586 + gap: 0.5rem; 1587 + } 1588 + 1589 + .reactions-move-ref { 1590 + font-size: 0.875rem; 1591 + font-weight: normal; 1592 + color: var(--sky-gray); 1593 + } 1594 + 1595 + .add-reaction-btn { 1596 + padding: 0.375rem 0.75rem; 1597 + background: var(--sky-cloud); 1598 + color: var(--sky-slate); 1599 + border: 1px solid var(--sky-blue-pale); 1600 + border-radius: 0.375rem; 1601 + font-size: 0.8rem; 1602 + font-weight: 500; 1603 + cursor: pointer; 1604 + transition: all 0.2s; 1605 + } 1606 + 1607 + .add-reaction-btn:hover { 1608 + background: var(--sky-blue-pale); 1609 + border-color: var(--sky-apricot); 1610 + } 1611 + 1612 + /* Reaction form */ 1613 + .reaction-form { 1614 + display: flex; 1615 + flex-direction: column; 1616 + gap: 0.75rem; 1617 + padding: 1rem; 1618 + background: var(--sky-cloud); 1619 + border-radius: 0.5rem; 1620 + margin-bottom: 1rem; 1621 + } 1622 + 1623 + .reaction-form-row { 1624 + display: flex; 1625 + gap: 0.5rem; 1626 + } 1627 + 1628 + .reaction-emoji-input { 1629 + width: 60px; 1630 + padding: 0.5rem; 1631 + border: 1px solid var(--sky-blue-pale); 1632 + border-radius: 0.375rem; 1633 + font-size: 1rem; 1634 + text-align: center; 1635 + } 1636 + 1637 + .reaction-stars-select { 1638 + flex: 1; 1639 + max-width: 100px; 1640 + padding: 0.5rem; 1641 + border: 1px solid var(--sky-blue-pale); 1642 + border-radius: 0.375rem; 1643 + font-size: 0.875rem; 1644 + background: var(--sky-white); 1645 + } 1646 + 1647 + .reaction-text-input { 1648 + width: 100%; 1649 + padding: 0.5rem 0.75rem; 1650 + border: 1px solid var(--sky-blue-pale); 1651 + border-radius: 0.375rem; 1652 + font-size: 0.875rem; 1653 + resize: vertical; 1654 + font-family: inherit; 1655 + } 1656 + 1657 + .reaction-text-input:focus, 1658 + .reaction-emoji-input:focus, 1659 + .reaction-stars-select:focus { 1660 + outline: none; 1661 + border-color: var(--sky-apricot); 1662 + box-shadow: 0 0 0 2px var(--sky-apricot-light); 1663 + } 1664 + 1665 + .submit-reaction-btn { 1666 + align-self: flex-end; 1667 + padding: 0.5rem 1rem; 1668 + background: linear-gradient(135deg, var(--sky-apricot-dark) 0%, var(--sky-apricot) 100%); 1669 + color: white; 1670 + border: none; 1671 + border-radius: 0.375rem; 1672 + font-size: 0.875rem; 1673 + font-weight: 600; 1674 + cursor: pointer; 1675 + transition: all 0.2s; 1676 + } 1677 + 1678 + .submit-reaction-btn:hover:not(:disabled) { 1679 + transform: translateY(-1px); 1680 + box-shadow: 0 2px 8px rgba(229, 168, 120, 0.3); 1681 + } 1682 + 1683 + .submit-reaction-btn:disabled { 1684 + opacity: 0.5; 1685 + cursor: not-allowed; 1686 + } 1687 + 1688 + /* Reactions list */ 1689 + .reactions-list { 1690 + display: flex; 1691 + flex-direction: column; 1692 + gap: 0.75rem; 1693 + max-height: 300px; 1694 + overflow-y: auto; 1695 + } 1696 + 1697 + .no-reactions { 1698 + text-align: center; 1699 + color: var(--sky-gray); 1700 + font-style: italic; 1701 + padding: 1rem; 1702 + } 1703 + 1704 + .reaction-item { 1705 + padding: 0.75rem 1rem; 1706 + background: var(--sky-cloud); 1707 + border-radius: 0.5rem; 1708 + border: 1px solid var(--sky-blue-pale); 1709 + } 1710 + 1711 + .reaction-item-header { 1712 + display: flex; 1713 + align-items: center; 1714 + gap: 0.5rem; 1715 + margin-bottom: 0.375rem; 1716 + } 1717 + 1718 + .reaction-item-emoji { 1719 + font-size: 1.25rem; 1720 + } 1721 + 1722 + .reaction-author { 1723 + color: var(--sky-slate); 1724 + font-weight: 500; 1725 + font-size: 0.875rem; 1726 + text-decoration: none; 1727 + transition: color 0.2s; 1728 + } 1729 + 1730 + .reaction-author:hover { 1731 + color: var(--sky-apricot-dark); 1732 + } 1733 + 1734 + .reaction-stars { 1735 + margin-left: auto; 1736 + color: var(--sky-apricot-dark); 1737 + font-size: 0.875rem; 1738 + letter-spacing: -1px; 1739 + } 1740 + 1741 + .reaction-text { 1742 + margin: 0; 1743 + color: var(--sky-slate-dark); 1744 + font-size: 0.875rem; 1745 + line-height: 1.4; 1305 1746 } 1306 1747 </style>