Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ype-4494-non-palette-highlight-paint-clear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@youversion/platform-react-ui': patch
Comment thread
cameronapak marked this conversation as resolved.
---

Paint and clear non-palette highlight colors in the web Bible reader.

Apply stays limited to the five SDK palette colors. Valid non-palette API hex now paints on the reader and appears in the remove tray at its exact color with the checkmark. Invalid hex is dropped from paint and the tray. Clear keeps the existing ANY rule for mixed selections. Dark fills (e.g. black) flip verse text and the remove checkmark to white in light mode so they stay legible.
11 changes: 8 additions & 3 deletions docs/adr/YPE-3705-controlled-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ path) via a `controlled` input, latched by `Root` at first mount:

## Out of scope

Notes, custom colors, >5 colors (epic fast-follow); self-contained notification
events; controlled *content* (the reader keeps fetching passages/books/versions);
offline/write-queue concerns (native-side, YPE-3717).
Notes; **creating** new custom apply colors (apply stays the five SDK palette
colors — YPE-4494); self-contained notification events; controlled *content*
(the reader keeps fetching passages/books/versions); offline/write-queue
concerns (native-side, YPE-3717).

Valid non-palette hex that already exists on the account **does** paint and can
be cleared via the remove tray (YPE-4494) — that is not a custom-color apply
path.
11 changes: 8 additions & 3 deletions docs/highlight-flow-statechart.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ to explore it interactively.

- **`booting`** → routes to `disabled` or `enabled` from the initial input.
- **`disabled`** — the flag is off **or** no auth provider is mounted. Fully
inert: no fetch, no writes, no dialogs. A color tap resolves to `noop`.
inert: no fetch, no writes, no dialogs. A color tap resolves to `noop`
(including empty selection and non-palette apply colors — apply stays
palette-only; see YPE-4494).
- **`enabled`** — a parallel state with two independent regions:
- **`flow`** — the auth / dialog flow.
- `resuming` consumes the data-exchange return exactly once, then routes on
Expand All @@ -26,8 +28,10 @@ to explore it interactively.
processing one queued operation at a time so a DELETE can never overtake an
in-flight POST for the same verse.

`TAP_COLOR` forks in `flow`: authorized (`applied`) → optimistic write; signed
out → `signInDialog`; signed in without the permission → `permissionDialog`.
`TAP_COLOR` forks in `flow`: authorized palette color (`applied`) → optimistic
write; signed out → `signInDialog`; signed in without the permission →
`permissionDialog`. Non-palette colors, empty verse selection, and invalid hex
resolve to `noop` at the machine boundary (YPE-4494).
Both dialog paths stash a pending highlight (10-min `sessionStorage` TTL) so the
intent survives the full-page redirect and resumes on a granted return.

Expand All @@ -50,6 +54,7 @@ stateDiagram-v2
state disabled {
note right of disabled
TAP_COLOR → outcome "noop"
(inert, non-palette, empty selection)
no fetch / writes / dialogs
end note
}
Expand Down
10 changes: 6 additions & 4 deletions packages/ui/src/components/bible-reader-controlled.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,15 +346,17 @@ describe('BibleReader controlled mode - pure projection', () => {
}
});

it('ignores entries with colors outside the built-in swatches (no un-removable paint)', () => {
it('paints valid non-palette colors and drops invalid hex', () => {
const highlights: Highlight[] = [
{ version_id: 111, passage_id: 'JHN.1.1', color: 'abcdef' },
{ version_id: 111, passage_id: 'JHN.1.2', color: YELLOW },
{ version_id: 111, passage_id: 'JHN.1.2', color: 'gggggg' },
{ version_id: 111, passage_id: 'JHN.1.3', color: YELLOW },
];
const { container } = renderReader({ highlights });

expect(getVerseEl(container, 1).style.backgroundColor).toBe('');
expect(getVerseEl(container, 2).style.backgroundColor).toBe(fillFor(YELLOW));
expect(getVerseEl(container, 1).style.backgroundColor).toBe(fillFor('abcdef'));
expect(getVerseEl(container, 2).style.backgroundColor).toBe('');
expect(getVerseEl(container, 3).style.backgroundColor).toBe(fillFor(YELLOW));
});

it('re-projects when the highlights prop changes (host round-trip)', () => {
Expand Down
42 changes: 21 additions & 21 deletions packages/ui/src/components/bible-reader-highlights-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,34 +124,34 @@ describe('bibleReaderHighlightsMachine — writeIntent lifecycle', () => {
const { ref, refetch } = makeServices({ createHighlight });
const actor = startMachine(ref);

// Write A: apply red to verse 16 (goes in flight).
actor.send({ type: 'TAP_COLOR', color: 'FF0000', verses: [16] });
// Write A: apply pink to verse 16 (goes in flight).
actor.send({ type: 'TAP_COLOR', color: 'ff95ef', verses: [16] });
await vi.waitFor(() => expect(createHighlight).toHaveBeenCalledTimes(1));
const claimA = actor.getSnapshot().context.writeIntent.get(16);
expect(claimA).toBeDefined();

// Write B: re-claim verse 16 with green while A is still in flight (queued).
actor.send({ type: 'TAP_COLOR', color: '00FF00', verses: [16] });
actor.send({ type: 'TAP_COLOR', color: '5dff79', verses: [16] });
const claimB = actor.getSnapshot().context.writeIntent.get(16);
expect(claimB).toBeDefined();
expect(claimB).not.toBe(claimA);
expect(actor.getSnapshot().context.overlay).toEqual({ 16: '00ff00' });
expect(actor.getSnapshot().context.overlay).toEqual({ 16: '5dff79' });

// A settles: it must not delete B's claim or reconcile verse 16.
first.resolve(undefined);
await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(1));
const afterA = actor.getSnapshot().context;
expect(afterA.writeIntent.get(16)).toBe(claimB);
expect(afterA.reconcile.has(16)).toBe(false);
expect(afterA.overlay).toEqual({ 16: '00ff00' });
expect(afterA.overlay).toEqual({ 16: '5dff79' });

// B settles: it cleans up its own claim and registers its reconcile entry.
await vi.waitFor(() => expect(createHighlight).toHaveBeenCalledTimes(2));
second.resolve(undefined);
await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(2));
const afterB = actor.getSnapshot().context;
expect(afterB.writeIntent.has(16)).toBe(false);
expect(afterB.reconcile.get(16)).toEqual({ op: 'apply', color: '00ff00' });
expect(afterB.reconcile.get(16)).toEqual({ op: 'apply', color: '5dff79' });
actor.stop();
});
});
Expand All @@ -167,16 +167,16 @@ describe('bibleReaderHighlightsMachine — pending stash on lost permission', ()
vi.spyOn(console, 'error').mockImplementation(vi.fn());

// Both taps issued before either write settles: A is writing, B is queued.
actor.send({ type: 'TAP_COLOR', color: 'AAAAAA', verses: [1, 2, 3] });
actor.send({ type: 'TAP_COLOR', color: 'BBBBBB', verses: [4, 5, 6] });
actor.send({ type: 'TAP_COLOR', color: 'fffe00', verses: [1, 2, 3] });
actor.send({ type: 'TAP_COLOR', color: '5dff79', verses: [4, 5, 6] });

await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(2));

const stash = peekPendingHighlights();
expect(stash).toHaveLength(2);
// Verse-level ordering deterministic: first-queued (A) first.
expect(stash[0]).toMatchObject({ verses: [1, 2, 3], color: 'aaaaaa' });
expect(stash[1]).toMatchObject({ verses: [4, 5, 6], color: 'bbbbbb' });
expect(stash[0]).toMatchObject({ verses: [1, 2, 3], color: 'fffe00' });
expect(stash[1]).toMatchObject({ verses: [4, 5, 6], color: '5dff79' });
actor.stop();
});

Expand All @@ -192,14 +192,14 @@ describe('bibleReaderHighlightsMachine — pending stash on lost permission', ()
const actor = startMachine(ref);
vi.spyOn(console, 'error').mockImplementation(vi.fn());

actor.send({ type: 'TAP_COLOR', color: 'AAAAAA', verses: [1, 2, 3] });
actor.send({ type: 'TAP_COLOR', color: 'BBBBBB', verses: [4, 5, 6] });
actor.send({ type: 'TAP_COLOR', color: 'fffe00', verses: [1, 2, 3] });
actor.send({ type: 'TAP_COLOR', color: '5dff79', verses: [4, 5, 6] });

await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(2));

const stash = peekPendingHighlights();
expect(stash).toHaveLength(1);
expect(stash[0]).toMatchObject({ verses: [1, 2, 3], color: 'aaaaaa' });
expect(stash[0]).toMatchObject({ verses: [1, 2, 3], color: 'fffe00' });
// The 5xx write's verses were never stashed.
expect(stash.some((entry) => entry.verses.includes(4))).toBe(false);
actor.stop();
Expand All @@ -212,7 +212,7 @@ describe('bibleReaderHighlightsMachine — pending stash on lost permission', ()
appendPendingHighlight(
{
verses: [1, 2, 3],
color: 'aaaaaa',
color: 'fffe00',
versionId: 111,
book: 'JHN',
chapter: '3',
Expand All @@ -223,7 +223,7 @@ describe('bibleReaderHighlightsMachine — pending stash on lost permission', ()
appendPendingHighlight(
{
verses: [4, 5, 6],
color: 'bbbbbb',
color: '5dff79',
versionId: 111,
book: 'JHN',
chapter: '3',
Expand All @@ -243,12 +243,12 @@ describe('bibleReaderHighlightsMachine — pending stash on lost permission', ()
expect(passages).toEqual(['JHN.3.1-3', 'JHN.3.4-6']);
// Both colors painted in the same-scope overlay.
expect(actor.getSnapshot().context.overlay).toEqual({
1: 'aaaaaa',
2: 'aaaaaa',
3: 'aaaaaa',
4: 'bbbbbb',
5: 'bbbbbb',
6: 'bbbbbb',
1: 'fffe00',
2: 'fffe00',
3: 'fffe00',
4: '5dff79',
5: '5dff79',
6: '5dff79',
});
// Pending consumed once resumed.
expect(readPendingHighlights()).toEqual([]);
Expand Down
14 changes: 12 additions & 2 deletions packages/ui/src/components/bible-reader-highlights-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
* leaving the tested apply-convergence behavior untouched.
*/
import { collapseVerseRuns, formatPassageId, type VerseRun } from '@/lib/usfm-ranges';
import { isPaletteHighlightColor } from '@/lib/highlight-colors';
import {
appendPendingHighlight,
clearPendingHighlight,
Expand Down Expand Up @@ -363,14 +364,20 @@ export const bibleReaderHighlightsMachine = setup({
signedOut: ({ context }) => !context.isAuthenticated,

// ── TAP_COLOR fork ──
tapInert: ({ event }) => event.type === 'TAP_COLOR' && event.verses.length === 0,
tapInert: ({ event }) =>
Comment thread
cameronapak marked this conversation as resolved.
event.type === 'TAP_COLOR' &&
(event.verses.length === 0 || !isPaletteHighlightColor(event.color)),
tapCanWrite: ({ context, event }) =>
event.type === 'TAP_COLOR' &&
event.verses.length > 0 &&
isPaletteHighlightColor(event.color) &&
context.isAuthenticated &&
context.services.current.hasHighlightsPermission(),
tapNeedsSignIn: ({ context, event }) =>
event.type === 'TAP_COLOR' && event.verses.length > 0 && !context.isAuthenticated,
event.type === 'TAP_COLOR' &&
event.verses.length > 0 &&
isPaletteHighlightColor(event.color) &&
!context.isAuthenticated,

// ── resume fork ──
// Guards must be pure, so they PEEK (never clear expired/malformed entries);
Expand Down Expand Up @@ -471,6 +478,7 @@ export const bibleReaderHighlightsMachine = setup({
/** Optimistically paint + claim + enqueue a user apply (TAP_COLOR authorized path). */
startApplyWrite: enqueueActions(({ enqueue, context, event }) => {
if (event.type !== 'TAP_COLOR') return;
if (!isPaletteHighlightColor(event.color)) return;
const color = event.color.toLowerCase();
const verses = event.verses;
const token = {};
Expand All @@ -497,6 +505,7 @@ export const bibleReaderHighlightsMachine = setup({
*/
stashPendingTap: enqueueActions(({ enqueue, context, event }) => {
if (event.type !== 'TAP_COLOR') return;
if (!isPaletteHighlightColor(event.color)) return;
stashPendingHighlight({
verses: event.verses,
color: event.color.toLowerCase(),
Expand Down Expand Up @@ -542,6 +551,7 @@ export const bibleReaderHighlightsMachine = setup({
if (pendings.length === 0) return;
clearPendingHighlight();
for (const pending of pendings) {
if (!isPaletteHighlightColor(pending.color)) continue;
const scope: HighlightScope = {
versionId: pending.versionId,
book: pending.book,
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/components/bible-reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,9 @@ export type RootProps = {
* "controlled, nothing highlighted"; leaving the prop off means
* self-contained.
*
* Entries whose color is outside the reader's five built-in swatches are
* ignored — the verse-action popover can only offer removal for its own
* palette, so an unmanageable color must not paint.
* Invalid API hex is dropped from paint. Valid non-palette colors paint and
* appear in the remove tray at their exact hex (YPE-4494). Apply stays
* palette-only.
*/
highlights?: Highlight[];
/**
Expand Down
Loading
Loading