👁️
5
fork

Configure Feed

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

enhance primer perf and exp

+370 -130
+56 -41
src/components/deck/PrimerSection.tsx
··· 1 + import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; 2 + import type { RefObject } from "react"; 1 3 import { useState } from "react"; 2 4 import { RichTextEditor } from "@/components/richtext/RichTextEditor"; 3 5 import { type ParseResult, RichText } from "@/lib/richtext"; 4 6 5 7 interface PrimerSectionProps { 6 - markdown: string; 7 - setMarkdown: (value: string) => void; 8 + inputRef: RefObject<HTMLTextAreaElement | null>; 9 + onInput: () => void; 10 + defaultValue: string; 8 11 parsed: ParseResult; 9 12 isDirty?: boolean; 10 - isPending?: boolean; 11 13 isSaving?: boolean; 12 14 readOnly?: boolean; 13 15 } 14 16 15 - const COLLAPSED_LINES = 4; 17 + const COLLAPSED_LINES = 8; 16 18 const LINE_HEIGHT = 1.5; 17 19 18 20 export function PrimerSection({ 19 - markdown, 20 - setMarkdown, 21 + inputRef, 22 + onInput, 23 + defaultValue, 21 24 parsed, 22 25 isDirty, 23 - isPending, 24 26 isSaving, 25 27 readOnly = false, 26 28 }: PrimerSectionProps) { ··· 34 36 if (isEditing && !readOnly) { 35 37 return ( 36 38 <div className="space-y-3"> 39 + <RichTextEditor 40 + inputRef={inputRef} 41 + onInput={onInput} 42 + defaultValue={defaultValue} 43 + parsed={parsed} 44 + isDirty={isDirty} 45 + isSaving={isSaving} 46 + placeholder="Write about your deck's strategy, key combos, card choices..." 47 + /> 37 48 <div className="flex justify-end"> 38 49 <button 39 50 type="button" 40 51 onClick={() => setIsEditing(false)} 41 - className="text-sm text-blue-600 dark:text-blue-400 hover:underline" 52 + className="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 42 53 > 43 54 Done 44 55 </button> 45 56 </div> 46 - <RichTextEditor 47 - markdown={markdown} 48 - setMarkdown={setMarkdown} 49 - parsed={parsed} 50 - isDirty={isDirty} 51 - isPending={isPending} 52 - isSaving={isSaving} 53 - placeholder="Write about your deck's strategy, key combos, card choices..." 54 - /> 55 57 </div> 56 58 ); 57 59 } ··· 73 75 } 74 76 75 77 return ( 76 - <div className="relative"> 77 - <div 78 - className={ 79 - !isExpanded && needsTruncation ? "overflow-hidden" : undefined 80 - } 81 - style={ 82 - !isExpanded && needsTruncation 83 - ? { maxHeight: `${COLLAPSED_LINES * LINE_HEIGHT}em` } 84 - : undefined 85 - } 86 - > 87 - <RichText 88 - text={parsed.text} 89 - facets={parsed.facets} 90 - className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap" 91 - /> 92 - </div> 78 + <div> 79 + <div className="relative"> 80 + <div 81 + className={ 82 + !isExpanded && needsTruncation ? "overflow-hidden" : undefined 83 + } 84 + style={ 85 + !isExpanded && needsTruncation 86 + ? { maxHeight: `${COLLAPSED_LINES * LINE_HEIGHT}em` } 87 + : undefined 88 + } 89 + > 90 + <RichText 91 + text={parsed.text} 92 + facets={parsed.facets} 93 + className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap" 94 + /> 95 + </div> 93 96 94 - {needsTruncation && !isExpanded && ( 95 - <div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white dark:from-slate-900 to-transparent" /> 96 - )} 97 + {needsTruncation && !isExpanded && ( 98 + <div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white dark:from-slate-900 to-transparent pointer-events-none" /> 99 + )} 100 + </div> 97 101 98 - <div className="flex items-center gap-3 mt-1"> 102 + <div className="flex items-center gap-2 mt-2"> 99 103 {needsTruncation && ( 100 104 <button 101 105 type="button" 102 106 onClick={() => setIsExpanded(!isExpanded)} 103 - className="text-sm text-blue-600 dark:text-blue-400 hover:underline" 107 + className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 104 108 > 105 - {isExpanded ? "Show less" : "Show more"} 109 + {isExpanded ? ( 110 + <> 111 + <ChevronUp className="w-4 h-4" /> 112 + Show less 113 + </> 114 + ) : ( 115 + <> 116 + <ChevronDown className="w-4 h-4" /> 117 + Show more 118 + </> 119 + )} 106 120 </button> 107 121 )} 108 122 {!readOnly && ( 109 123 <button 110 124 type="button" 111 125 onClick={() => setIsEditing(true)} 112 - className="text-sm text-blue-600 dark:text-blue-400 hover:underline" 126 + className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 113 127 > 128 + <Pencil className="w-4 h-4" /> 114 129 Edit 115 130 </button> 116 131 )}
+15 -16
src/components/richtext/RichTextEditor.tsx
··· 1 + import type { RefObject } from "react"; 1 2 import type { ParseResult } from "@/lib/richtext"; 2 3 import { RichText } from "@/lib/richtext"; 3 4 ··· 5 6 6 7 function getSaveState({ 7 8 isDirty, 8 - isPending, 9 9 isSaving, 10 10 }: { 11 11 isDirty?: boolean; 12 - isPending?: boolean; 13 12 isSaving?: boolean; 14 13 }): SaveState { 15 14 if (isSaving) return "saving"; 16 - // isPending (debounce buffering) or isDirty both show as "dirty" 17 - if (isPending || isDirty) return "dirty"; 15 + if (isDirty) return "dirty"; 18 16 return "saved"; 19 17 } 20 18 ··· 45 43 } 46 44 47 45 export interface RichTextEditorProps { 48 - markdown: string; 49 - setMarkdown: (value: string) => void; 46 + inputRef: RefObject<HTMLTextAreaElement | null>; 47 + onInput: () => void; 48 + defaultValue: string; 50 49 parsed: ParseResult; 51 50 isDirty?: boolean; 52 - isPending?: boolean; 53 51 isSaving?: boolean; 54 52 placeholder?: string; 55 53 className?: string; 56 54 } 57 55 58 56 export function RichTextEditor({ 59 - markdown, 60 - setMarkdown, 57 + inputRef, 58 + onInput, 59 + defaultValue, 61 60 parsed, 62 61 isDirty, 63 - isPending, 64 62 isSaving, 65 63 placeholder = "Write something...", 66 64 className, 67 65 }: RichTextEditorProps) { 68 - const saveState = getSaveState({ isDirty, isPending, isSaving }); 66 + const saveState = getSaveState({ isDirty, isSaving }); 69 67 70 68 return ( 71 69 <div className={className}> 72 - <div className="grid grid-cols-2 gap-4"> 73 - <div className="relative"> 70 + <div className="grid grid-cols-2 gap-4 items-stretch"> 71 + <div className="relative flex flex-col"> 74 72 <textarea 75 - value={markdown} 76 - onChange={(e) => setMarkdown(e.target.value)} 73 + ref={inputRef} 74 + defaultValue={defaultValue} 75 + onInput={onInput} 77 76 placeholder={placeholder} 78 - className="w-full h-64 p-3 pr-8 border border-gray-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 resize-y font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" 77 + className="w-full min-h-64 flex-1 p-3 pr-8 border border-gray-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 resize-y font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" 79 78 /> 80 79 <div className="absolute top-2 right-2"> 81 80 <SaveIndicator state={saveState} />
+40 -31
src/lib/richtext/renderer.tsx
··· 1 - import type { ReactNode } from "react"; 1 + import { memo, type ReactNode } from "react"; 2 2 import { ByteString } from "./byte-string"; 3 3 import { 4 4 type Facet, ··· 29 29 mention: MentionFeature | null; 30 30 } 31 31 32 - export function RichText({ 32 + export const RichText = memo(function RichText({ 33 33 text, 34 - facets = [], 34 + facets, 35 35 className, 36 36 }: RichTextProps): ReactNode { 37 37 if (!text) { 38 38 return null; 39 39 } 40 40 41 - const segments = segmentText(text, facets); 41 + const segments = segmentText(text, facets ?? []); 42 42 43 43 return ( 44 44 <span className={className}> 45 45 {segments.map((segment, i) => renderSegment(segment, i))} 46 46 </span> 47 47 ); 48 - } 49 - 50 - type Wrapper = (content: ReactNode, key: string) => ReactNode; 48 + }); 51 49 52 50 function renderSegment(segment: Segment, key: number): ReactNode { 53 51 if (segment.codeBlock) { ··· 61 59 ); 62 60 } 63 61 64 - const wrappers: Wrapper[] = []; 62 + // Plain text - no wrapper needed 63 + if ( 64 + !segment.bold && 65 + !segment.italic && 66 + !segment.code && 67 + !segment.link && 68 + !segment.mention 69 + ) { 70 + return segment.text; 71 + } 65 72 73 + let content: ReactNode = segment.text; 74 + 75 + // Wrap in formatting elements (innermost to outermost) 66 76 if (segment.code) { 67 - wrappers.push((c, k) => ( 68 - <code key={k} className="bg-gray-100 dark:bg-slate-800 px-1 rounded"> 69 - {c} 77 + content = ( 78 + <code className="bg-gray-100 dark:bg-slate-800 px-1 rounded font-mono text-sm"> 79 + {content} 70 80 </code> 71 - )); 81 + ); 72 82 } 73 83 if (segment.italic) { 74 - wrappers.push((c, k) => <em key={k}>{c}</em>); 84 + content = <em>{content}</em>; 75 85 } 76 86 if (segment.bold) { 77 - wrappers.push((c, k) => <strong key={k}>{c}</strong>); 87 + content = <strong>{content}</strong>; 78 88 } 89 + 90 + // Links and mentions wrap the formatted content 79 91 if (segment.link) { 80 - const uri = segment.link.uri; 81 - wrappers.push((c, k) => ( 92 + return ( 82 93 <a 83 - key={k} 84 - href={uri} 94 + key={key} 95 + href={segment.link.uri} 85 96 className="text-blue-600 dark:text-blue-400 hover:underline" 86 97 target="_blank" 87 98 rel="noopener noreferrer" 88 99 > 89 - {c} 100 + {content} 90 101 </a> 91 - )); 102 + ); 92 103 } 104 + 93 105 if (segment.mention) { 94 - const did = segment.mention.did; 95 - wrappers.push((c, k) => ( 106 + return ( 96 107 <span 97 - key={k} 98 - className="text-blue-600 dark:text-blue-400 cursor-pointer hover:underline" 99 - data-did={did} 108 + key={key} 109 + className="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer" 110 + data-did={segment.mention.did} 100 111 > 101 - {c} 112 + {content} 102 113 </span> 103 - )); 114 + ); 104 115 } 105 116 106 - return wrappers.reduce<ReactNode>( 107 - (content, wrap, i) => wrap(content, `${key}-${i}`), 108 - segment.text, 109 - ); 117 + // Non-link/mention formatted content needs a keyed wrapper 118 + return <span key={key}>{content}</span>; 110 119 } 111 120 112 121 function collectFeatures(
+185 -1
src/lib/useDebounce.ts
··· 1 - import { useCallback, useEffect, useRef, useState } from "react"; 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 2 3 3 export interface UseDebounceResult<T> { 4 4 value: T; ··· 39 39 40 40 return { value: debouncedValue, flush, isPending }; 41 41 } 42 + 43 + export interface UseThrottleResult<T> { 44 + value: T; 45 + flush: () => T; 46 + isPending: boolean; 47 + } 48 + 49 + /** 50 + * Throttle: emits value at most once per `interval` ms. 51 + * Unlike debounce, this guarantees periodic updates during continuous input. 52 + */ 53 + export function useThrottle<T>( 54 + value: T, 55 + interval: number, 56 + ): UseThrottleResult<T> { 57 + const [throttledValue, setThrottledValue] = useState<T>(value); 58 + const lastEmitRef = useRef<number>(Date.now()); 59 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 60 + const latestValueRef = useRef(value); 61 + 62 + latestValueRef.current = value; 63 + 64 + const isPending = value !== throttledValue; 65 + 66 + const flush = useCallback(() => { 67 + if (timeoutRef.current) { 68 + clearTimeout(timeoutRef.current); 69 + timeoutRef.current = null; 70 + } 71 + setThrottledValue(latestValueRef.current); 72 + lastEmitRef.current = Date.now(); 73 + return latestValueRef.current; 74 + }, []); 75 + 76 + useEffect(() => { 77 + const now = Date.now(); 78 + const elapsed = now - lastEmitRef.current; 79 + 80 + if (elapsed >= interval) { 81 + setThrottledValue(value); 82 + lastEmitRef.current = now; 83 + } else { 84 + // Schedule emit for remaining time 85 + timeoutRef.current = setTimeout(() => { 86 + setThrottledValue(latestValueRef.current); 87 + lastEmitRef.current = Date.now(); 88 + timeoutRef.current = null; 89 + }, interval - elapsed); 90 + } 91 + 92 + return () => { 93 + if (timeoutRef.current) { 94 + clearTimeout(timeoutRef.current); 95 + } 96 + }; 97 + }, [value, interval]); 98 + 99 + return { value: throttledValue, flush, isPending }; 100 + } 101 + 102 + export interface ImperativeThrottle<T> { 103 + /** Call with new value - will emit on throttle boundary */ 104 + update: (value: T) => void; 105 + /** Force emit current value immediately */ 106 + flush: () => void; 107 + /** Get current throttled value */ 108 + getValue: () => T; 109 + } 110 + 111 + /** 112 + * Imperative throttle: call update() on every input, emits via callback at most once per interval. 113 + * Unlike the hook version, this doesn't require state as input - you control when to call update(). 114 + */ 115 + export function useImperativeThrottle<T>( 116 + initialValue: T, 117 + interval: number, 118 + onEmit: (value: T) => void, 119 + ): ImperativeThrottle<T> { 120 + const latestValueRef = useRef(initialValue); 121 + const emittedValueRef = useRef(initialValue); 122 + const lastEmitRef = useRef<number>(0); 123 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 124 + const onEmitRef = useRef(onEmit); 125 + 126 + onEmitRef.current = onEmit; 127 + 128 + return useMemo(() => { 129 + const emit = () => { 130 + const value = latestValueRef.current; 131 + if (value !== emittedValueRef.current) { 132 + emittedValueRef.current = value; 133 + onEmitRef.current(value); 134 + } 135 + lastEmitRef.current = Date.now(); 136 + }; 137 + 138 + return { 139 + update: (value: T) => { 140 + latestValueRef.current = value; 141 + 142 + const now = Date.now(); 143 + const elapsed = now - lastEmitRef.current; 144 + 145 + if (elapsed >= interval) { 146 + if (timeoutRef.current) { 147 + clearTimeout(timeoutRef.current); 148 + timeoutRef.current = null; 149 + } 150 + emit(); 151 + } else if (!timeoutRef.current) { 152 + timeoutRef.current = setTimeout(() => { 153 + timeoutRef.current = null; 154 + emit(); 155 + }, interval - elapsed); 156 + } 157 + }, 158 + flush: () => { 159 + if (timeoutRef.current) { 160 + clearTimeout(timeoutRef.current); 161 + timeoutRef.current = null; 162 + } 163 + emit(); 164 + }, 165 + getValue: () => latestValueRef.current, 166 + }; 167 + }, [interval]); 168 + } 169 + 170 + export interface ImperativeDebounce<T> { 171 + /** Call with new value - will emit after delay of inactivity */ 172 + update: (value: T) => void; 173 + /** Force emit current value immediately */ 174 + flush: () => void; 175 + /** Get current value */ 176 + getValue: () => T; 177 + } 178 + 179 + /** 180 + * Imperative debounce: call update() on every input, emits via callback after delay of inactivity. 181 + */ 182 + export function useImperativeDebounce<T>( 183 + initialValue: T, 184 + delay: number, 185 + onEmit: (value: T) => void, 186 + ): ImperativeDebounce<T> { 187 + const latestValueRef = useRef(initialValue); 188 + const emittedValueRef = useRef(initialValue); 189 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 190 + const onEmitRef = useRef(onEmit); 191 + 192 + onEmitRef.current = onEmit; 193 + 194 + return useMemo(() => { 195 + const emit = () => { 196 + const value = latestValueRef.current; 197 + if (value !== emittedValueRef.current) { 198 + emittedValueRef.current = value; 199 + onEmitRef.current(value); 200 + } 201 + }; 202 + 203 + return { 204 + update: (value: T) => { 205 + latestValueRef.current = value; 206 + 207 + if (timeoutRef.current) { 208 + clearTimeout(timeoutRef.current); 209 + } 210 + timeoutRef.current = setTimeout(() => { 211 + timeoutRef.current = null; 212 + emit(); 213 + }, delay); 214 + }, 215 + flush: () => { 216 + if (timeoutRef.current) { 217 + clearTimeout(timeoutRef.current); 218 + timeoutRef.current = null; 219 + } 220 + emit(); 221 + }, 222 + getValue: () => latestValueRef.current, 223 + }; 224 + }, [delay]); 225 + }
+73 -40
src/lib/useRichText.ts
··· 1 - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 1 + import { 2 + type RefObject, 3 + startTransition, 4 + useCallback, 5 + useEffect, 6 + useMemo, 7 + useRef, 8 + useState, 9 + } from "react"; 2 10 import { type ParseResult, parseMarkdown } from "./richtext"; 3 - import { useDebounce } from "./useDebounce"; 11 + import { useImperativeDebounce, useImperativeThrottle } from "./useDebounce"; 4 12 5 13 export interface UseRichTextOptions { 6 14 initialValue?: string; 7 15 onSave?: (parsed: ParseResult) => void | Promise<void>; 8 - debounceMs?: number; 16 + /** How often to update preview during typing (ms) */ 17 + previewThrottleMs?: number; 18 + /** How long to wait after typing stops before saving (ms) */ 19 + saveDebounceMs?: number; 9 20 } 10 21 11 22 export interface UseRichTextResult { 12 - markdown: string; 13 - setMarkdown: (value: string) => void; 23 + inputRef: RefObject<HTMLTextAreaElement | null>; 24 + onInput: () => void; 25 + defaultValue: string; 14 26 parsed: ParseResult; 15 27 isDirty: boolean; 16 - isPending: boolean; // debounce pending (keystrokes buffered) 17 28 save: () => void; 18 29 } 19 30 20 31 export function useRichText({ 21 32 initialValue = "", 22 33 onSave, 23 - debounceMs = 1500, 34 + previewThrottleMs = 100, 35 + saveDebounceMs = 1500, 24 36 }: UseRichTextOptions = {}): UseRichTextResult { 25 - const [markdown, setMarkdownRaw] = useState(initialValue); 37 + const inputRef = useRef<HTMLTextAreaElement | null>(null); 26 38 const savedValueRef = useRef(initialValue); 27 39 const onSaveRef = useRef(onSave); 28 - const prevDebouncedRef = useRef(initialValue); 40 + 41 + // State only updates on throttle/debounce boundaries, not every keystroke 42 + const [previewMarkdown, setPreviewMarkdown] = useState(initialValue); 43 + const [saveState, setSaveState] = useState<"saved" | "dirty">("saved"); 29 44 30 45 onSaveRef.current = onSave; 31 46 32 - const { 33 - value: debouncedMarkdown, 34 - flush, 35 - isPending, 36 - } = useDebounce(markdown, debounceMs); 47 + // Throttle for preview (update every N ms during typing) 48 + const throttle = useImperativeThrottle( 49 + initialValue, 50 + previewThrottleMs, 51 + (value) => { 52 + // Low-priority update - React can interrupt for user input 53 + startTransition(() => { 54 + setPreviewMarkdown(value); 55 + // Mark dirty when preview updates (throttle boundary) 56 + if (value !== savedValueRef.current) { 57 + setSaveState("dirty"); 58 + } 59 + }); 60 + }, 61 + ); 37 62 38 - const parsed = useMemo(() => parseMarkdown(markdown), [markdown]); 39 - 40 - const isDirty = markdown !== savedValueRef.current; 63 + // Debounce for save (wait N ms after typing stops) 64 + const debounce = useImperativeDebounce( 65 + initialValue, 66 + saveDebounceMs, 67 + (value) => { 68 + if (value !== savedValueRef.current && onSaveRef.current) { 69 + onSaveRef.current(parseMarkdown(value)); 70 + savedValueRef.current = value; 71 + } 72 + setSaveState("saved"); 73 + }, 74 + ); 41 75 42 - // Call onSave when debounced value changes (not on every render) 43 - if (debouncedMarkdown !== prevDebouncedRef.current) { 44 - prevDebouncedRef.current = debouncedMarkdown; 45 - if (debouncedMarkdown !== savedValueRef.current && onSaveRef.current) { 46 - onSaveRef.current(parseMarkdown(debouncedMarkdown)); 47 - savedValueRef.current = debouncedMarkdown; 48 - } 49 - } 76 + const parsed = useMemo( 77 + () => parseMarkdown(previewMarkdown), 78 + [previewMarkdown], 79 + ); 50 80 51 - const setMarkdown = useCallback((value: string) => { 52 - setMarkdownRaw(value); 53 - }, []); 81 + const onInput = useCallback(() => { 82 + const value = inputRef.current?.value ?? ""; 83 + throttle.update(value); 84 + debounce.update(value); 85 + // No setState here - dirty state updates on throttle boundary 86 + }, [throttle, debounce]); 54 87 55 88 const save = useCallback(() => { 56 - const current = flush(); 57 - if (current !== savedValueRef.current && onSaveRef.current) { 58 - onSaveRef.current(parseMarkdown(current)); 59 - savedValueRef.current = current; 60 - } 61 - }, [flush]); 89 + throttle.flush(); 90 + debounce.flush(); 91 + }, [throttle, debounce]); 62 92 63 93 // Sync with external initialValue changes (e.g., deck reload) 64 94 useEffect(() => { 65 - setMarkdownRaw(initialValue); 95 + if (inputRef.current) { 96 + inputRef.current.value = initialValue; 97 + } 98 + setPreviewMarkdown(initialValue); 66 99 savedValueRef.current = initialValue; 67 - prevDebouncedRef.current = initialValue; 100 + setSaveState("saved"); 68 101 }, [initialValue]); 69 102 70 103 return { 71 - markdown, 72 - setMarkdown, 104 + inputRef, 105 + onInput, 106 + defaultValue: initialValue, 73 107 parsed, 74 - isDirty, 75 - isPending, 108 + isDirty: saveState === "dirty", 76 109 save, 77 110 }; 78 111 }
+1 -1
src/routes/profile/$did/deck/$rkey/index.tsx
··· 169 169 if (!isOwner) return; 170 170 mutation.mutate({ ...deck, primer: parsed }); 171 171 }, 172 - debounceMs: 1500, 172 + saveDebounceMs: 1500, 173 173 }); 174 174 175 175 // Helper to update deck via mutation