Final wiring of all things highlights API and auth-guard, with fix for highlight not deleting immediately (visually)#294
Merged
Conversation
🦋 Changeset detectedLatest commit: 7b91105 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
This was referenced Jul 20, 2026
cameronapak
marked this pull request as ready for review
July 20, 2026 20:18
cameronapak
force-pushed
the
claude/ype-1034-pr3-recent-colors
branch
from
July 21, 2026 19:46
2f47c5f to
8a6ae88
Compare
A pending highlight resumed after sign-in/data-exchange now routes write failures through the same handling as a user-initiated apply: 401/403 invalidates the permission cache, re-stashes the pending highlight (in its own scope), and re-opens the permission dialog instead of silently dropping the user's tap. No unattended redirect loop: the dialog requires a click. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness: - Gate the color row on auth-provider presence (highlightsInteractive) so copy/share-only integrations don't render inert swatches - Exit permissionDialog to idle and clear pending when auth flips signed-out Efficiency: - Keep serverColors reference-stable across equal refetches (no chapter-wide verse re-sweep per write) - Stabilize useApiData refetch identity by holding fetchFn in a ref Reuse/simplification: - Export getHttpStatus from core; machine consumes it instead of probing the ad-hoc error shape - Extract resolveAuthToken (core) to de-dupe getAuthToken in highlights and data-exchange clients - Add internal useApiClient hook; refactor five hooks onto it - Drop derivable WriteOp.reprompt; share scopesEqual from the machine Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
xstate@5.32.5 was published 2026-07-14, inside the 3-day minimumReleaseAge window, so every CI job failed at pnpm install. Pin to 5.32.4 (published 2026-07-02) and rebuild the lockfile from a fresh resolution, per the policy's own guidance. Bump back once 5.32.5 ages out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The callback saved granted_permissions for whoever was in localStorage
when the return page loaded — if user B signed in on another tab
mid-redirect, B inherited A's grant and hasPermission('highlights')
skipped B's consent (review finding).
The redirect leg now records the initiating user's id; the callback
reads-and-clears it and only saves the grant when it matches the current
user. Mismatch or missing initiator fails closed: grant discarded, result
downgraded to failure, URL still cleaned — the flow re-prompts instead of
proceeding as granted. Sign-out clears the initiator. The combined
sign-in+permissions path is unaffected: it returns through the sign-in
callback, which binds the grant to the user from the same ID token.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cameronapak
force-pushed
the
claude/ype-1034-pr5-hardening
branch
from
July 21, 2026 20:01
98f5693 to
905e592
Compare
cameronapak
changed the base branch from
claude/ype-1034-pr3-recent-colors
to
main
July 21, 2026 20:01
…ing on 5xx Save the data-exchange initiator before minting the token, and stop clearing the pending stash on apply network/5xx so a sibling permission-lost intent survives. Co-authored-by: Cursor <cursoragent@cursor.com>
cameronapak
changed the base branch from
main
to
claude/ype-1034-pr3-recent-colors
July 22, 2026 16:05
cameronapak
changed the base branch from
claude/ype-1034-pr3-recent-colors
to
main
July 22, 2026 16:05
Keep controlled mode always interactive (YPE-3705) while self-contained still requires HIGHLIGHTS_LIVE and an auth provider. Co-authored-by: Cursor <cursoragent@cursor.com>
Take aria-hidden from main; keep this branch's checkmark-tap remove test. Co-authored-by: Cursor <cursoragent@cursor.com>
Stash granted_permissions across the web OAuth hop and seed from token scope; sync appName/signInPromptMessage onto the UI-bundled core so the sign-in dialog pitch reaches consumers of the UI package. Co-authored-by: Cursor <cursoragent@cursor.com>
An abandoned sign-in flow (consent granted, tab closed before the final code round-trip) left the pre-code granted_permissions stash in localStorage. A later sign-in by a different user would union that stale stash into its own grant set and seed it into the optimistic permission cache under the wrong user. Clear the stash when starting a new flow; it is only ever produced later during the callback pre-code hop and never needs to survive a signIn. Mirrors the data-exchange initiator binding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prevent cross-flow permission cache seeding and leftover data-exchange initiator binding after mint failure or a new sign-in. Co-authored-by: Cursor <cursoragent@cursor.com>
The real DELETE /v1/highlights/{id} returns 200 application/json with an
empty body. ApiClient called response.json() on it, which throws, so
every successful delete surfaced as a failed write. The highlights
machine then (correctly, for a real failure) reverted its remove
overlay, repainting the stale highlight until the refetch caught up —
the "removed highlight flashes back" bug.
Read the body as text and treat empty as undefined. Regression coverage
stubs global.fetch and drives the real client + machine + adapter, the
only test shape that can catch response-parsing bugs (client-method
mocks cannot).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flips the YPE-1034 dark-launch flag. The vapor delete-flash bug is fixed and regression-tested; the optimistic-overlay staleness trade-off (local overlay wins over server truth until a refetch reflects the write) is signed off as acceptable for launch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Highlight fills now render at 1.0 opacity in light mode and 0.3 in dark mode (Swift BibleTextView+Rendering parity), replacing the flat 0.35. Highlighted verse number labels paint white in dark mode so they stay legible over the fill; both fill and label styles clear on removal. ADR YPE-642 corrected to record the as-built values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The logo component hardcoded an English aria-label default, which bypassed localization. The label is now a required prop; the sign-in dialog already passes the localized string. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stash payload is already JSON; comma-joining the permissions into a string was a needless round-trip. Unknown shapes fail closed (the stash only lives across a single in-flight OAuth redirect). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The learned-preferences blocks were one developer's session notes, not repo guidance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six stacked-PR changesets merge into highlights-server-backed.md: one consumer-facing entry announcing server-backed highlights as live, with an overt callout that earlier releases accidentally shipped a demo-only localStorage implementation. Controlled-mode and aria-label entries stay separate; the aria-label entry now covers the required-label change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Highlight fills get 4px rounded corners with box-decoration-break: clone, so each wrapped line fragment carries its own rounded ends and 2px inline padding instead of a square cutoff at the wrap. The padding and rounding are static on every verse span, so applying or removing a highlight only changes the background color and never reflows text. Verse-action popover swatches now preview the real fill in dark mode (same 0.3 alpha as applied highlights) with a legible checkmark and stroke. Deliberate web-native divergence from Swift's square fills, recorded in ADR YPE-642. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The signInPromptMessage prop stays in the SDK (Swift parity, hidden when unset); the demo just should not showcase it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opening the verse-action popover moved focus onto the first swatch, and because the trigger is a click on non-focusable verse text, browsers treat Radix's deferred programmatic focus as keyboard-like, so the focus-visible ring flashed on every mouse/touch open. Initial focus now lands on the popover content itself (tabIndex -1 via onOpenAutoFocus); Tab moves to the first swatch with the ring visible. Verified live in the demo for both modalities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parseGrantedPermissions only read the bare granted_permissions key, but the auth server encodes permission lists with bracket-array notation (observed as requested_permissions[]=highlights in its consent redirect). A same-shape echo on the sign-in or data-exchange callback was silently discarded, so a one-shot sign-in grant never persisted and the permission dialog re-prompted. The parser now also accepts granted_permissions[] and indexed granted_permissions[n]; bare-key parsing, comma splitting, and de-dup are unchanged, and the state-bound stash and cross-user clearing are untouched. Callback-level regression tests cover both the final-callback echo and the pre-code stash hop in bracket form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jaredhightower-youversion
left a comment
Collaborator
There was a problem hiding this comment.
Simplification pass — a few candidates to delete or shrink; all non-blocking.
…pe grants
Under React StrictMode the auth init effect double-invokes and both runs
call handleAuthCallback with the same single-use code still in the URL. The
duplicate exchange 400s and its catch path calls clearAuthTokens (which also
clears the granted-permissions cache the winning run just seeded), producing a
spurious "highlights permission needed" dialog after a successful sign-in.
- Dedupe the code-for-token exchange by authorization code via a module-scoped
map, so repeated/concurrent callbacks share one exchange (one token request,
no destructive catch path). Failed exchanges are shared too and not retried.
- Widen getHttpStatus to recognize any non-null object with a numeric status
(subsumes the Error case) so a thrown plain {status: 401} is honored.
- Regression tests: core dedupe (single fetch, grants survive), StrictMode
provider tolerance, and getHttpStatus plain-object/Error/unrelated cases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- core: inline granted_permissions regex; drop dead .filter(Boolean) - core: makeTokens/setupCallbackFlow/stubSignInCrypto helpers in Users.test - ui: delete unused Xcode asset-catalog logo folder; fix doc reference - ui: shared highlights-test-utils (collection/mockUserInfo/deferred/Providers) - ui: drop duplicate checkmark-clear test; simplify css read, waitFor, refs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…'t wipe the session Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ght fills Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…omits the grant echo On the web sign-in flow, a user can request `highlights`, grant consent, and still receive ZERO grant evidence back to the SPA. Captured live 2026-07-23: the pre-code hop carried only `state`, the code hop `scope="profile openid"`, and the token body `scope: "profile openid"`. All three existing grant sources in exchangeCodeForTokens (URL echo, state-bound pre-code stash, token scope) come back empty, so saveGrantedPermissions never runs and the highlights machine re-prompts for permission immediately after sign-in. Bridge the server contract gap client-side: signIn now stashes the REQUESTED data-exchange permissions bound to the OAuth `state`, and the callback reads, clears, and unions them into the granted set (same fail-closed state check as the pre-code stash, still filtered by OIDC_SCOPES). The seed is optimistic and self-correcting: a 401/403 on the first write drops the permission and the machine's PERMISSION_LOST path re-prompts. The Set union means a future server echo never double-counts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rack viewport for the width cap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oduct decision) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jaredhightower-youversion
approved these changes
Jul 23, 2026
jaredhightower-youversion
left a comment
Collaborator
There was a problem hiding this comment.
LGTM!! Awesome job on this game. It's been a journey, but we've gotten to this point. I did watch the video. The small little nuances with the thing getting cut off: not sure if you were able to resolve that. If not, no worries. A small follow-up PR should be able to resolve that.
Dustin-Kelley
self-requested a review
July 24, 2026 14:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PLZ WATCH VIDEO▶️
Summary
Server-backed BibleReader highlights go live: the dark-launch flag flips on after a final round of auth hardening, a fix for successful deletes being misread as failures, and Swift-parity UX corrections. Last PR of the YPE-1034 stack (#283 → #285 → #286 → this one); the consolidated changelog announces the launch and the removal of the accidentally shipped localStorage-only highlights.
Changes
Highlights are enabled by default (
HIGHLIGHTS_LIVEon) — signed-in readers' highlights persist to their YouVersion account; six stacked changesets are consolidated into one launch changelog.Removing a highlight no longer flashes back before disappearing — the core
ApiClienttreated empty-body 2xx JSON responses (the real DELETE shape) as failures, so the machine reverted the successful removal; regression tests now drive the real client through a stubbedfetch.A data-exchange grant only applies to the user who initiated it — initiator recorded at redirect, read-and-cleared at callback, any mismatch fails closed;
signInalso clears stale permission stashes so an abandoned flow can't leak grants across users.A resumed pending write that fails 401/403 re-prompts for permission instead of silently reverting the reader's tap.
Sign-in matches the Swift SDK:
appName/signInPromptMessageprops, Platform wordmark, comma-joinedrequested_permissionswire format, and a permission cache seeded from token scope so a highlight tap doesn't re-prompt right after OAuth.Dark mode matches the Swift SDK: highlight fills render at 0.3 opacity (1.0 in light mode), verse numbers paint white over dark-mode fills, and popover swatches preview the dimmed color a highlight will actually apply; fills also get subtly rounded corners with per-line-fragment rounding at wraps (deliberate web-native divergence from Swift's square fills); the active swatch shows a checkmark instead of an X.
Hardening and cleanup: xstate pinned to exactly 5.32.4 (supply-chain cooldown), review findings across all three packages (color row gated on interactivity, stable
serverColors/refetchidentities, shareduseApiClient), the logo's accessible label is a required localized prop, the pending-permissions stash storesstring[], and personal workflow notes are stripped from AGENTS.md.One-shot sign-in grants survive the server's URL encoding:
parseGrantedPermissionsnow also accepts bracket-array keys (granted_permissions[], indexed forms) that the auth server emits, on both the sign-in callback and the data-exchange return — previously the echoed grant was silently discarded and the permission dialog re-prompted after a successful sign-in.StrictMode-proof auth: the OAuth code exchange and the token refresh are each shared across concurrent invocations, so a duplicate's 4xx can no longer clear the session or the just-seeded grants (previously the demo's
<StrictMode>double-invoke could wipe a fresh sign-in or grant cache).One-shot sign-in survives a silent server: a live instrumented capture (2026-07-23) showed the web callback carries no
granted_permissionsecho on either hop and the token scope isprofile openidonly — the SDK had zero evidence of the consent the user just gave. The permissions requested atsignInare now stashed (state-bound, fail-closed) and optimistically seeded as granted on a successful exchange; an over-optimistic seed self-corrects through the existing 401/403 → permission re-prompt path. The durable fix is server-side (echo the grant on the web callback) and is being filed with Platform.Verse-number labels and footnote icons inherit the verse text color over highlight fills in both themes, so saturated light-mode fills no longer clash with muted gray glyphs (deliberate divergence from Swift, which only recolors the label in dark mode).
Reviewer simplification pass applied in full (12 comments, −286 lines: shared highlight test fixtures, dead helper/config removal, duplicate test deleted) and
getHttpStatusnow recognizes plain{status}objects, not justErrorinstances.Mobile: the verse-action popover caps at the viewport width (with live tracking across mid-open resizes) and the swatch row scrolls horizontally with edge fades that appear only where content is hidden; Copy/Share stay pinned. Verified live at 300px.
Fill opacity churn, net zero: a same-day design decision briefly moved light-mode fills to 30% opacity and was then walked back — final state is the original 1.0 light / 0.3 dark (Swift parity). Kept in history as commits cf6a291/959f2ca and revert 7b91105.
Start here: change 3 — auth-boundary logic; the fail-closed path is the part worth adversarial review.
Test plan
pnpm build && pnpm typecheck && pnpm lint && pnpm test: all green (1014 tests — core 369 / hooks 289 / ui 356)Needs manual check: one live confirmation in the vite demo — remove a highlight while signed in and confirm no flash. The one-shot flow's root cause was pinned down via a breadcrumb-instrumented live run (server returns no grant evidence; see change 10) and the fix is regression-tested against that exact captured shape. Live-verified 2026-07-23: signed-out tap → sign-in → OAuth → highlight applies with no second dialog.
Greptile Summary
This is the final PR in the YPE-1034 stack, flipping server-backed BibleReader highlights to live. It ships a substantial auth hardening pass alongside the feature launch: the empty-body DELETE flash fix, StrictMode-safe code-exchange and token-refresh deduplication, initiator-bound data-exchange grant safety, bracket-array permission parsing, and optimistic permission seeding for the web flow's missing server grant echo.
ApiClientnow reads the response body as text first and treats an empty body as "no data", so a200 application/jsonwith no payload no longer throws and surfaces a successful delete as a failure.parseGrantedPermissionsaccepts bracket-array keys (granted_permissions[],granted_permissions[0]), and requested permissions are optimistically seeded on callback (state-bound, self-correcting via 401/403 → re-prompt) to work around the server returning no grant echo on the web flow.Confidence Score: 4/5
Safe to merge except for the light-mode fill opacity constant, which doesn't match the product decision documented in the PR description.
The auth hardening, DELETE flash fix, and StrictMode deduplication are all well-designed and regression-tested. The one concrete gap is that
HIGHLIGHT_FILL_OPACITY_LIGHTis still1.0even though the PR description's product decision (item 14) explicitly changed light mode to 30% — matching dark mode — and says swatches should preview the dimmed fill in both modes. The ADR update was written before that decision landed and still documents the pre-decision split. Everything else in the 63-file change looks correct.packages/ui/src/components/verse.tsx (fill opacity constant) and packages/ui/src/components/verse-action-popover.tsx (swatch preview in light mode)
Important Files Changed
getHttpStatuswith duck-type check (accepts any object with a numericstatusproperty, not just Error instances), and fixes empty-body 2xx handling so DELETE responses no longer surface as JSON parse failures.granted_permissions[],granted_permissions[0]) via regex to handle PHP/Rails server encoding; extractsparsePermissionListfor token-scope parsing. Correctly handles all observed server formats.repromptfield fromWriteOp— all apply-kind writes (user-initiated and resume-applied) now re-prompt on 401/403, fixing silent drop of resumed taps. AddssignedOutguard to auto-dismiss the permission dialog on sign-out.HIGHLIGHT_FILL_OPACITY_LIGHT = 1.0contradicts the PR description's stated product decision that both modes should render at 30%.dataExchangeInitiatorstorage (save/get/clear) and wiresclearDataExchangeInitiatorintoclearAuthTokensso a sign-out always drops a pending initiator.{ optional: true }) returns null instead of throwing for auth-tolerant callers.useEvent-stylefetchFnRefpattern to keepfetchFnout offetchData's deps, stabilizingrefetchidentity across renders without stale-closure risk.computeScrollFadefunction, ResizeObserver-tracked), viewport-width cap, corrected auto-focus on pointer-open, and theme-aware swatch previews.highlightsInteractiveflag and a reference-stableserverColorsmemoization (content-equality comparison via ref) to prevent spurious verse-style re-sweeps on no-op refetches.Reviews (14): Last reviewed commit: "revert(ui): restore full-strength light-..." | Re-trigger Greptile