Reader: visible text selection in every theme; highlights follow across translations
Build and Push Docker Image / build-and-push (push) Failing after 19s

Selection
- Selected text uses a per-theme colour (it was nearly invisible on Dark)
- The colour picker also appears on touch screens after dragging the selection
  handles, and sits below the selection there so the system menu doesn't cover it

Highlights
- Each highlight remembers its translation and whether it covers the whole verse
- In another translation the same words are found again (ignoring case and
  punctuation); whole-verse highlights cover the whole verse; when the wording
  differs, the verse gets a small colour dot instead of marking the wrong words
- Older highlights are checked against their saved words instead of trusted
- Server keeps the new fields

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 09:55:09 -04:00
1 parent aabd4267b8
commit a0971eab29
9 files changed
+189 -38

No files matched your search

+2 -1
View File
@@ -9,6 +9,7 @@ import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
import { isWholeVerse } from '../../utils/highlightSpans.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import VerseShareImage from '../VerseShareImage.jsx';
import IqLexicon from '../iq/IqLexicon.jsx';
@@ -223,7 +224,7 @@ export default function VerseSheet({
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">
<span className="mr-1 text-xs text-[var(--r-sub)]">Highlight</span>
{HIGHLIGHT_COLORS.map((color) => {
const on = verseHighlights.some((h) => h.color === color && h.startOffset === 0 && h.endOffset >= verse.text.length);
const on = verseHighlights.some((h) => h.color === color && isWholeVerse(h, verse.text));
return (
<button key={color} type="button" onClick={() => onHighlightVerse(color)} aria-pressed={on}
aria-label={`Highlight verse ${colorTitle(color, colorLabels).toLowerCase()}`} data-tip={`${colorTitle(color, colorLabels)}${on ? ' — tap to remove' : ''}`}
@@ -0,0 +1,23 @@
import { test, expect } from 'vitest';
import { READER_THEMES } from './readerThemes.js';
// Contrast of two #rrggbb colours (WCAG ratio), used to make sure selected text can be seen on every theme.
const lum = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const mix = (bg, rgba) => {
const [r, g, b, a] = rgba.match(/[\d.]+/g).map(Number);
return `#${[r, g, b].map((c, i) => Math.round(c * a + parseInt(bg.slice(1 + i * 2, 3 + i * 2), 16) * (1 - a)).toString(16).padStart(2, '0')).join('')}`;
};
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
test('every theme sets a selection colour that is different from the page and keeps text readable', () => {
for (const [id, theme] of Object.entries(READER_THEMES)) {
const v = theme.vars;
expect(v['--r-sel'], id).toBeTruthy();
const selected = mix(v['--r-bg'], v['--r-sel']);
expect(ratio(selected, v['--r-bg']), `${id}: selection stands out from the page`).toBeGreaterThan(1.25);
expect(ratio(selected, v['--r-text']), `${id}: text stays readable on the selection`).toBeGreaterThan(4);
}
});
+3 -3
View File
@@ -3,15 +3,15 @@
export const READER_THEMES = {
paper: {
label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-sel': 'rgba(168, 78, 39, 0.3)', '--r-on': '#ffffff', '--r-danger': '#b42318' },
},
sepia: {
label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-sel': 'rgba(147, 71, 29, 0.32)', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
},
dark: {
label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-sel': 'rgba(224, 137, 92, 0.5)', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
},
};