diff --git a/src/colorUtils.ts b/src/colorUtils.ts new file mode 100644 index 00000000..86871f8e --- /dev/null +++ b/src/colorUtils.ts @@ -0,0 +1,13 @@ +export function readableTextColor(hex: string): '#000' | '#fff' { + const h = hex.replace('#', ''); + const r = parseInt(h.slice(0, 2), 16) / 255; + const g = parseInt(h.slice(2, 4), 16) / 255; + const b = parseInt(h.slice(4, 6), 16) / 255; + const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); + const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + return L > 0.42 ? '#000' : '#fff'; +} + +export function withInkOpacity(ink: string, opacity: number): string { + return ink === '#000' ? `rgba(0,0,0,${opacity})` : `rgba(255,255,255,${opacity})`; +} diff --git a/src/components/Boards/ListBoard.tsx b/src/components/Boards/ListBoard.tsx index 34520913..ceb09738 100644 --- a/src/components/Boards/ListBoard.tsx +++ b/src/components/Boards/ListBoard.tsx @@ -8,31 +8,13 @@ import { shallowEqual } from 'react-redux'; import { selectGameById } from '../../../redux/GamesSlice'; import { useAppSelector } from '../../../redux/hooks'; +import { readableTextColor, withInkOpacity } from '../../colorUtils'; import DialOverlay from '../Interactions/Dial/DialOverlay'; import { useMenuOpen } from '../MenuOpenContext'; import { bottomSheetHeight } from '../Sheets/GameSheet'; const ROW_BOARD_PADDING = 12; -// TODO: consolidate inkFor/inkA into a shared src/colorUtils.ts module and rename: -// inkFor → readableColor (use getContrastRatio from 'colorsheet', add data migration -// to backfill player colors, then introduce usePlayerColors hook) -// inkA → withOpacity -// Same change needed in DialOverlay.tsx and DialControl.tsx. -function inkFor(hex: string): string { - const h = hex.replace('#', ''); - const r = parseInt(h.slice(0, 2), 16) / 255; - const g = parseInt(h.slice(2, 4), 16) / 255; - const b = parseInt(h.slice(4, 6), 16) / 255; - const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); - const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); - return L > 0.42 ? '#000' : '#fff'; -} - -function inkA(ink: string, a: number): string { - return ink === '#000' ? `rgba(0,0,0,${a})` : `rgba(255,255,255,${a})`; -} - interface PlayerRowProps { playerId: string; index: number; @@ -87,13 +69,13 @@ const PlayerRow: React.FC = ({ playerId, index, svDimmed, disabl if (!playerName) return null; - const ink = inkFor(color); + const ink = readableTextColor(color); const separatorSign = currentRoundScore < 0 ? '−' : '+'; const roundAbs = Math.abs(currentRoundScore); const secondaryNumberStyle = { - color: inkA(ink, 0.45), + color: withInkOpacity(ink, 0.45), fontSize: 18, fontWeight: '600' as const, lineHeight: 22, @@ -107,13 +89,13 @@ const PlayerRow: React.FC = ({ playerId, index, svDimmed, disabl fontVariant: ['tabular-nums' as const] }; const captionStyle = { - color: inkA(ink, 0.65), + color: withInkOpacity(ink, 0.65), fontSize: 8, fontWeight: '800' as const, letterSpacing: 1.0, marginTop: 1 }; - const operatorStyle = { color: inkA(ink, 0.5), fontSize: 16, fontWeight: '500' as const }; + const operatorStyle = { color: withInkOpacity(ink, 0.5), fontSize: 16, fontWeight: '500' as const }; return ( @@ -137,8 +119,8 @@ const PlayerRow: React.FC = ({ playerId, index, svDimmed, disabl /* Locked: hide PREV/RND, show winner pill in that slot */ isWinner && ( - - WINNER + + WINNER ) ) : ( diff --git a/src/components/Interactions/Dial/DialControl.tsx b/src/components/Interactions/Dial/DialControl.tsx index bc1a1275..8e08a203 100644 --- a/src/components/Interactions/Dial/DialControl.tsx +++ b/src/components/Interactions/Dial/DialControl.tsx @@ -17,6 +17,8 @@ import Animated, { } from 'react-native-reanimated'; import Svg, { Circle, Line, Path } from 'react-native-svg'; +import { withInkOpacity } from '../../../colorUtils'; + // Extend TextInputProps to include Reanimated's animated text content prop type AnimatedTextInputProps = TextInputProps & { text?: string; @@ -42,11 +44,6 @@ export function getCenterValueFontScale(value: number): number { return Math.max(CENTER_VALUE_MIN_SCALE, Math.min(1, CENTER_VALUE_TARGET_CHARS / textLength)); } -// TODO: see ListBoard.tsx — consolidate inkFor/inkA into shared colorUtils module -function inkA(ink: string, a: number): string { - return ink === '#000' ? `rgba(0,0,0,${a})` : `rgba(255,255,255,${a})`; -} - function fmtSigned(v: number): string { 'worklet'; if (v > 0) return '+' + v; @@ -383,7 +380,7 @@ const DialControl: React.FC = ({ // --- SVG geometry --- const ringColor = isSecondary ? ACCENT : ink; - const trackColor = inkA(ink, 0.18); + const trackColor = withInkOpacity(ink, 0.18); // Tick marks — memoized, only recomputed when dial size changes const ticks = useMemo(() => Array.from({ length: 12 }, (_, i) => { @@ -402,10 +399,10 @@ const DialControl: React.FC = ({ {!landscape && ( - + STEP +{pillActive ? addendTwo : addendOne} @@ -475,7 +472,7 @@ const DialControl: React.FC = ({ underlineColorAndroid="transparent" /> - + THIS ROUND @@ -510,7 +507,7 @@ const DialControl: React.FC = ({ }} style={({ pressed }) => [ styles.stepBtn, - { backgroundColor: inkA(ink, pressed ? 0.26 : 0.15), width: D * 0.265, height: D * 0.224 }, + { backgroundColor: withInkOpacity(ink, pressed ? 0.26 : 0.15), width: D * 0.265, height: D * 0.224 }, ]} > @@ -528,7 +525,7 @@ const DialControl: React.FC = ({ caretHidden={true} underlineColorAndroid="transparent" /> - + NEW TOTAL @@ -543,7 +540,7 @@ const DialControl: React.FC = ({ }} style={({ pressed }) => [ styles.stepBtn, - { backgroundColor: inkA(ink, pressed ? 0.26 : 0.15), width: D * 0.265, height: D * 0.224 }, + { backgroundColor: withInkOpacity(ink, pressed ? 0.26 : 0.15), width: D * 0.265, height: D * 0.224 }, ]} > diff --git a/src/components/Interactions/Dial/DialOverlay.tsx b/src/components/Interactions/Dial/DialOverlay.tsx index a17e1d93..c7c4bc16 100644 --- a/src/components/Interactions/Dial/DialOverlay.tsx +++ b/src/components/Interactions/Dial/DialOverlay.tsx @@ -18,6 +18,7 @@ import { useAppDispatch, useAppSelector } from '../../../../redux/hooks'; import { playerRoundScoreSet, selectPlayerById, selectPlayerRoundStats } from '../../../../redux/PlayersSlice'; import { selectCurrentGame } from '../../../../redux/selectors'; import { setLastUsedInteractionType } from '../../../../redux/SettingsSlice'; +import { readableTextColor, withInkOpacity } from '../../../colorUtils'; import { useMenuOpen } from '../../MenuOpenContext'; import { InteractionType } from '../InteractionType'; @@ -41,21 +42,6 @@ function resistedDrag(t: number): number { return t / (1 + t / 400); // nearly 1:1 at small pulls, strong resistance beyond ~150 px } -// TODO: see ListBoard.tsx — consolidate inkFor/inkA into shared colorUtils module -function inkFor(hex: string): string { - const h = hex.replace('#', ''); - const r = parseInt(h.slice(0, 2), 16) / 255; - const g = parseInt(h.slice(2, 4), 16) / 255; - const b = parseInt(h.slice(4, 6), 16) / 255; - const lin = (c: number) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); - const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); - return L > 0.42 ? '#000' : '#fff'; -} - -function inkA(ink: string, a: number): string { - return ink === '#000' ? `rgba(0,0,0,${a})` : `rgba(255,255,255,${a})`; -} - function bounded(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } @@ -168,7 +154,7 @@ const PlayerDialPage: React.FC = ({ if (!player) return null; - const ink = inkFor(player.color ?? '#444'); + const ink = readableTextColor(player.color ?? '#444'); const playerColor = player.color ?? '#444'; const isLandscape = pageWidth > pageHeight; @@ -177,8 +163,8 @@ const PlayerDialPage: React.FC = ({ if (isLandscape) { const lsDialSize = Math.min(Math.round((pageHeight - 52 * scale) / 1.25), Math.round(200 * scale)); - const stepBg = isSecondary ? LS_ACCENT : inkA(ink, 0.12); - const stepDotBg = isSecondary ? '#fff' : inkA(ink, 0.4); + const stepBg = isSecondary ? LS_ACCENT : withInkOpacity(ink, 0.12); + const stepDotBg = isSecondary ? '#fff' : withInkOpacity(ink, 0.4); const stepTextColor = isSecondary ? '#fff' : ink; return ( @@ -191,7 +177,7 @@ const PlayerDialPage: React.FC = ({ paddingHorizontal: 20 * scale, paddingBottom: 6 * scale, }]}> - + {player.playerName} @@ -209,7 +195,7 @@ const PlayerDialPage: React.FC = ({ {previousTotal} - PREVIOUS TOTAL + PREVIOUS TOTAL = ({ {currentRoundTotalScore} - NEW TOTAL + NEW TOTAL [ styles.lsDoneBtn, { - backgroundColor: inkA(ink, pressed ? 0.28 : 0.16), + backgroundColor: withInkOpacity(ink, pressed ? 0.28 : 0.16), height: 44 * scale, borderRadius: 14 * scale, }, @@ -280,7 +266,7 @@ const PlayerDialPage: React.FC = ({ {/* Top group: drag handle + name + prev total — also the swipe-to-dismiss zone */} - + = ({ {previousTotal} - PREVIOUS TOTAL + PREVIOUS TOTAL @@ -319,7 +305,7 @@ const PlayerDialPage: React.FC = ({ style={({ pressed }) => [ styles.doneBtn, { - backgroundColor: inkA(ink, pressed ? 0.28 : 0.16), + backgroundColor: withInkOpacity(ink, pressed ? 0.28 : 0.16), height: 48 * scale, borderRadius: 14 * scale, },